Merge changes from topic "KMP-navigation-common" into androidx-main

* changes:
  Add a multiplatform SynchronizedObject to the navigation-common.
  Configure kotlin multiplatform build in the navigation-common.
diff --git a/activity/activity-compose/build.gradle b/activity/activity-compose/build.gradle
index 00222883..d18f0eb 100644
--- a/activity/activity-compose/build.gradle
+++ b/activity/activity-compose/build.gradle
@@ -46,9 +46,9 @@
 
     androidTestImplementation("androidx.annotation:annotation:1.8.1")
     androidTestImplementation("androidx.compose.foundation:foundation-layout:1.6.0")
-    androidTestImplementation project(":compose:ui:ui-test-junit4")
-    androidTestImplementation project(":compose:material:material")
-    androidTestRuntimeOnly project(":compose:test-utils")
+    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+    androidTestImplementation(project(":compose:material:material"))
+    androidTestRuntimeOnly(project(":compose:test-utils"))
     androidTestImplementation(project(":compose:foundation:foundation"))
     androidTestImplementation(project(":compose:runtime:runtime"))
     androidTestImplementation(project(":compose:ui:ui"))
@@ -57,7 +57,7 @@
     androidTestImplementation(project(":compose:ui:ui-text"))
     androidTestImplementation(project(":lifecycle:lifecycle-common"))
     androidTestImplementation(project(":lifecycle:lifecycle-runtime"))
-    androidTestImplementation project(":lifecycle:lifecycle-runtime-testing")
+    androidTestImplementation(project(":lifecycle:lifecycle-runtime-testing"))
     androidTestImplementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.2")
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
diff --git a/activity/activity-compose/samples/build.gradle b/activity/activity-compose/samples/build.gradle
index d178013..55c901d 100644
--- a/activity/activity-compose/samples/build.gradle
+++ b/activity/activity-compose/samples/build.gradle
@@ -34,12 +34,12 @@
     implementation(libs.kotlinStdlib)
     implementation(libs.kotlinCoroutinesCore)
 
-    compileOnly project(":annotation:annotation-sampled")
+    compileOnly(project(":annotation:annotation-sampled"))
     api("androidx.compose.foundation:foundation-layout:1.0.1")
     api("androidx.compose.runtime:runtime:1.0.1")
     implementation "androidx.compose.foundation:foundation:1.0.1"
-    implementation project(":activity:activity-compose")
-    implementation project(":activity:activity")
+    implementation(project(":activity:activity-compose"))
+    implementation(project(":activity:activity"))
     implementation("androidx.compose.ui:ui-graphics:1.0.1")
     implementation("androidx.compose.ui:ui-text:1.0.1")
     implementation("androidx.compose.ui:ui:1.0.1")
diff --git a/activity/activity/src/main/java/androidx/activity/ComponentDialog.kt b/activity/activity/src/main/java/androidx/activity/ComponentDialog.kt
index 83593d6..c8c2782 100644
--- a/activity/activity/src/main/java/androidx/activity/ComponentDialog.kt
+++ b/activity/activity/src/main/java/androidx/activity/ComponentDialog.kt
@@ -60,7 +60,6 @@
         return bundle
     }
 
-    @Suppress("ClassVerificationFailure") // needed for onBackInvokedDispatcher call
     @CallSuper
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
diff --git a/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt b/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
index b26fdae..081d01e 100644
--- a/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
+++ b/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
@@ -601,7 +601,7 @@
              * Note that this does not check for any Intent handled by
              * [ACTION_SYSTEM_FALLBACK_PICK_IMAGES].
              */
-            @SuppressLint("ClassVerificationFailure", "NewApi")
+            @SuppressLint("NewApi")
             @Deprecated(
                 message =
                     "This method is deprecated in favor of isPhotoPickerAvailable(context) " +
@@ -687,7 +687,7 @@
              * Android version, the SDK extension version or the picker provided by a system app
              * implementing [ACTION_SYSTEM_FALLBACK_PICK_IMAGES].
              */
-            @SuppressLint("ClassVerificationFailure", "NewApi")
+            @SuppressLint("NewApi")
             @JvmStatic
             fun isPhotoPickerAvailable(context: Context): Boolean {
                 return isSystemPickerAvailable() || isSystemFallbackPickerAvailable(context)
@@ -700,7 +700,7 @@
              * Note that this does not check for any Intent handled by
              * [ACTION_SYSTEM_FALLBACK_PICK_IMAGES].
              */
-            @SuppressLint("ClassVerificationFailure", "NewApi")
+            @SuppressLint("NewApi")
             @JvmStatic
             internal fun isSystemPickerAvailable(): Boolean {
                 return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -871,7 +871,7 @@
         }
 
         @CallSuper
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         override fun createIntent(context: Context, input: PickVisualMediaRequest): Intent {
             // Check to see if the photo picker is available
             return if (PickVisualMedia.isSystemPickerAvailable()) {
@@ -949,7 +949,7 @@
              *
              * @see MediaStore.EXTRA_PICK_IMAGES_MAX
              */
-            @SuppressLint("NewApi", "ClassVerificationFailure")
+            @SuppressLint("NewApi")
             internal fun getMaxItems() =
                 if (PickVisualMedia.isSystemPickerAvailable()) {
                     MediaStore.getPickImagesMaxLimit()
diff --git a/appcompat/appcompat/build.gradle b/appcompat/appcompat/build.gradle
index 27328bd..aa3833d 100644
--- a/appcompat/appcompat/build.gradle
+++ b/appcompat/appcompat/build.gradle
@@ -71,7 +71,7 @@
     testImplementation(libs.junit)
     testImplementation(libs.robolectric)
 
-    lintPublish project(":appcompat:appcompat-lint")
+    lintPublish(project(":appcompat:appcompat-lint"))
 }
 
 android {
@@ -79,11 +79,6 @@
         // This disables the builds tools automatic vector -> PNG generation
         generatedDensities = []
     }
-
-    sourceSets {
-        main.res.srcDirs += "src/main/res-public"
-    }
-
     aaptOptions {
         additionalParameters "--no-version-vectors"
         noCompress "ttf"
diff --git a/appcompat/appcompat/src/main/res-public/values/public_attrs.xml b/appcompat/appcompat/src/main/res/values/public_attrs.xml
similarity index 100%
rename from appcompat/appcompat/src/main/res-public/values/public_attrs.xml
rename to appcompat/appcompat/src/main/res/values/public_attrs.xml
diff --git a/appcompat/appcompat/src/main/res-public/values/public_layouts.xml b/appcompat/appcompat/src/main/res/values/public_layouts.xml
similarity index 100%
rename from appcompat/appcompat/src/main/res-public/values/public_layouts.xml
rename to appcompat/appcompat/src/main/res/values/public_layouts.xml
diff --git a/appcompat/appcompat/src/main/res-public/values/public_styles.xml b/appcompat/appcompat/src/main/res/values/public_styles.xml
similarity index 100%
rename from appcompat/appcompat/src/main/res-public/values/public_styles.xml
rename to appcompat/appcompat/src/main/res/values/public_styles.xml
diff --git a/appsearch/appsearch-builtin-types/api/1.1.0-beta01.txt b/appsearch/appsearch-builtin-types/api/1.1.0-beta01.txt
index c3032f2..afd7275 100644
--- a/appsearch/appsearch-builtin-types/api/1.1.0-beta01.txt
+++ b/appsearch/appsearch-builtin-types/api/1.1.0-beta01.txt
@@ -93,8 +93,8 @@
   }
 
   @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class BuiltInCorpusFilters {
-    method public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
-    method public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(value=android.Manifest.permission.QUERY_ALL_PACKAGES, conditional=true) public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(android.Manifest.permission.READ_CONTACTS) public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
   }
 
   @androidx.appsearch.annotation.Document(name="builtin:ContactPoint") public class ContactPoint extends androidx.appsearch.builtintypes.Thing {
diff --git a/appsearch/appsearch-builtin-types/api/current.txt b/appsearch/appsearch-builtin-types/api/current.txt
index c3032f2..afd7275 100644
--- a/appsearch/appsearch-builtin-types/api/current.txt
+++ b/appsearch/appsearch-builtin-types/api/current.txt
@@ -93,8 +93,8 @@
   }
 
   @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class BuiltInCorpusFilters {
-    method public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
-    method public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(value=android.Manifest.permission.QUERY_ALL_PACKAGES, conditional=true) public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(android.Manifest.permission.READ_CONTACTS) public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
   }
 
   @androidx.appsearch.annotation.Document(name="builtin:ContactPoint") public class ContactPoint extends androidx.appsearch.builtintypes.Thing {
diff --git a/appsearch/appsearch-builtin-types/api/restricted_1.1.0-beta01.txt b/appsearch/appsearch-builtin-types/api/restricted_1.1.0-beta01.txt
index 62256a0..a929d58 100644
--- a/appsearch/appsearch-builtin-types/api/restricted_1.1.0-beta01.txt
+++ b/appsearch/appsearch-builtin-types/api/restricted_1.1.0-beta01.txt
@@ -95,8 +95,8 @@
   }
 
   @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class BuiltInCorpusFilters {
-    method public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
-    method public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(value=android.Manifest.permission.QUERY_ALL_PACKAGES, conditional=true) public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(android.Manifest.permission.READ_CONTACTS) public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
   }
 
   @androidx.appsearch.annotation.Document(name="builtin:ContactPoint") public class ContactPoint extends androidx.appsearch.builtintypes.Thing {
diff --git a/appsearch/appsearch-builtin-types/api/restricted_current.txt b/appsearch/appsearch-builtin-types/api/restricted_current.txt
index 62256a0..a929d58 100644
--- a/appsearch/appsearch-builtin-types/api/restricted_current.txt
+++ b/appsearch/appsearch-builtin-types/api/restricted_current.txt
@@ -95,8 +95,8 @@
   }
 
   @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class BuiltInCorpusFilters {
-    method public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
-    method public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(value=android.Manifest.permission.QUERY_ALL_PACKAGES, conditional=true) public static androidx.appsearch.app.SearchSpec.Builder searchMobileApplicationCorpus(androidx.appsearch.app.SearchSpec.Builder);
+    method @RequiresPermission(android.Manifest.permission.READ_CONTACTS) public static androidx.appsearch.app.SearchSpec.Builder searchPersonCorpus(androidx.appsearch.app.SearchSpec.Builder);
   }
 
   @androidx.appsearch.annotation.Document(name="builtin:ContactPoint") public class ContactPoint extends androidx.appsearch.builtintypes.Thing {
diff --git a/appsearch/appsearch-builtin-types/build.gradle b/appsearch/appsearch-builtin-types/build.gradle
index 17d6d34..e78170c 100644
--- a/appsearch/appsearch-builtin-types/build.gradle
+++ b/appsearch/appsearch-builtin-types/build.gradle
@@ -30,14 +30,14 @@
 
 dependencies {
     api(libs.jspecify)
-    implementation project(":appsearch:appsearch")
+    implementation(project(":appsearch:appsearch"))
     implementation("androidx.core:core:1.8.0")
 
-    annotationProcessor project(":appsearch:appsearch-compiler")
+    annotationProcessor(project(":appsearch:appsearch-compiler"))
 
-    androidTestImplementation project(":appsearch:appsearch")
-    androidTestImplementation project(":appsearch:appsearch-local-storage")
-    androidTestImplementation project(":appsearch:appsearch-test-util")
+    androidTestImplementation(project(":appsearch:appsearch"))
+    androidTestImplementation(project(":appsearch:appsearch-local-storage"))
+    androidTestImplementation(project(":appsearch:appsearch-test-util"))
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
diff --git a/appsearch/appsearch-builtin-types/src/main/java/androidx/appsearch/builtintypes/BuiltInCorpusFilters.java b/appsearch/appsearch-builtin-types/src/main/java/androidx/appsearch/builtintypes/BuiltInCorpusFilters.java
index a7eb3ba..caaa0aa 100644
--- a/appsearch/appsearch-builtin-types/src/main/java/androidx/appsearch/builtintypes/BuiltInCorpusFilters.java
+++ b/appsearch/appsearch-builtin-types/src/main/java/androidx/appsearch/builtintypes/BuiltInCorpusFilters.java
@@ -15,8 +15,10 @@
  */
 package androidx.appsearch.builtintypes;
 
+import android.Manifest;
 import android.content.pm.PackageManager;
 
+import androidx.annotation.RequiresPermission;
 import androidx.appsearch.app.ExperimentalAppSearchApi;
 import androidx.appsearch.app.SearchSpec;
 import androidx.core.util.Preconditions;
@@ -30,7 +32,8 @@
 @ExperimentalAppSearchApi
 public class BuiltInCorpusFilters {
     /**
-     * Adds a filter for the {@link MobileApplication} corpus to the given {@link SearchSpec.Builder}.
+     * Adds a filter for the {@link MobileApplication} corpus to the given {@link
+     * SearchSpec.Builder}.
      *
      * <p>The MobileApplication corpus is a corpus of all apps on the device. Each document contains
      * information about the app such as label, package id, and icon uri. This corpus is useful for
@@ -48,6 +51,7 @@
      * @param builder The {@link SearchSpec.Builder} to add the filter to.
      * @return The modified {@link SearchSpec.Builder}.
      */
+    @RequiresPermission(value = Manifest.permission.QUERY_ALL_PACKAGES, conditional = true)
     public static SearchSpec.@NonNull Builder searchMobileApplicationCorpus(
             SearchSpec.@NonNull Builder builder) {
         return Preconditions.checkNotNull(builder)
@@ -74,6 +78,7 @@
      * @param builder The {@link SearchSpec.Builder} to add the filter to.
      * @return The modified {@link SearchSpec.Builder}.
      */
+    @RequiresPermission(value = Manifest.permission.READ_CONTACTS)
     public static SearchSpec.@NonNull Builder searchPersonCorpus(
             SearchSpec.@NonNull Builder builder) {
         return Preconditions.checkNotNull(builder)
diff --git a/appsearch/appsearch-debug-view/build.gradle b/appsearch/appsearch-debug-view/build.gradle
index 8647343..ba6b515 100644
--- a/appsearch/appsearch-debug-view/build.gradle
+++ b/appsearch/appsearch-debug-view/build.gradle
@@ -35,9 +35,9 @@
 
 dependencies {
     api(libs.jspecify)
-    implementation project(":appsearch:appsearch")
-    implementation project(":appsearch:appsearch-local-storage")
-    implementation project(":appsearch:appsearch-platform-storage")
+    implementation(project(":appsearch:appsearch"))
+    implementation(project(":appsearch:appsearch-local-storage"))
+    implementation(project(":appsearch:appsearch-platform-storage"))
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
     implementation("androidx.constraintlayout:constraintlayout:2.2.0-beta01")
     implementation("androidx.core:core:1.5.0")
diff --git a/appsearch/appsearch-debug-view/samples/build.gradle b/appsearch/appsearch-debug-view/samples/build.gradle
index 1adaced..c681993 100644
--- a/appsearch/appsearch-debug-view/samples/build.gradle
+++ b/appsearch/appsearch-debug-view/samples/build.gradle
@@ -34,13 +34,13 @@
 }
 
 dependencies {
-    annotationProcessor project(":appsearch:appsearch-compiler")
+    annotationProcessor(project(":appsearch:appsearch-compiler"))
 
     api("androidx.annotation:annotation:1.1.0")
 
-    implementation project(":appsearch:appsearch")
-    implementation project(":appsearch:appsearch-local-storage")
-    implementation project(":appsearch:appsearch-debug-view")
+    implementation(project(":appsearch:appsearch"))
+    implementation(project(":appsearch:appsearch-local-storage"))
+    implementation(project(":appsearch:appsearch-debug-view"))
     implementation("androidx.appcompat:appcompat:1.2.0")
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
     implementation("com.google.android.material:material:1.0.0")
diff --git a/appsearch/appsearch-ktx/build.gradle b/appsearch/appsearch-ktx/build.gradle
index 22e454b..1910a48 100644
--- a/appsearch/appsearch-ktx/build.gradle
+++ b/appsearch/appsearch-ktx/build.gradle
@@ -33,10 +33,10 @@
 dependencies {
     api(libs.kotlinStdlib)
 
-    kaptAndroidTest project(":appsearch:appsearch-compiler")
-    androidTestImplementation project(":appsearch:appsearch")
-    androidTestImplementation project(":appsearch:appsearch-local-storage")
-    androidTestImplementation project(":appsearch:appsearch-test-util")
+    kaptAndroidTest(project(":appsearch:appsearch-compiler"))
+    androidTestImplementation(project(":appsearch:appsearch"))
+    androidTestImplementation(project(":appsearch:appsearch-local-storage"))
+    androidTestImplementation(project(":appsearch:appsearch-test-util"))
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
diff --git a/appsearch/appsearch-local-storage/build.gradle b/appsearch/appsearch-local-storage/build.gradle
index 255f767..566676d 100644
--- a/appsearch/appsearch-local-storage/build.gradle
+++ b/appsearch/appsearch-local-storage/build.gradle
@@ -88,7 +88,7 @@
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
     implementation("androidx.core:core:1.6.0")
 
-    androidTestImplementation project(":appsearch:appsearch-test-util")
+    androidTestImplementation(project(":appsearch:appsearch-test-util"))
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
index 7f68e51..af20e81 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
@@ -3701,11 +3701,11 @@
         }
 
         StorageInfo storageInfo1 = mAppSearchImpl.getStorageInfoForPackage("package1");
-        assertThat(storageInfo1.getBlobSizeBytes()).isEqualTo(15 * 1024);
-        assertThat(storageInfo1.getBlobCount()).isEqualTo(2);
+        assertThat(storageInfo1.getBlobsSizeBytes()).isEqualTo(15 * 1024);
+        assertThat(storageInfo1.getBlobsCount()).isEqualTo(2);
         StorageInfo storageInfo2 = mAppSearchImpl.getStorageInfoForPackage("package2");
-        assertThat(storageInfo2.getBlobSizeBytes()).isEqualTo(20 * 1024);
-        assertThat(storageInfo2.getBlobCount()).isEqualTo(1);
+        assertThat(storageInfo2.getBlobsSizeBytes()).isEqualTo(20 * 1024);
+        assertThat(storageInfo2.getBlobsCount()).isEqualTo(1);
     }
 
 
@@ -3754,11 +3754,11 @@
         }
 
         StorageInfo storageInfo1 = mAppSearchImpl.getStorageInfoForDatabase("package", "db1");
-        assertThat(storageInfo1.getBlobSizeBytes()).isEqualTo(15 * 1024);
-        assertThat(storageInfo1.getBlobCount()).isEqualTo(2);
+        assertThat(storageInfo1.getBlobsSizeBytes()).isEqualTo(15 * 1024);
+        assertThat(storageInfo1.getBlobsCount()).isEqualTo(2);
         StorageInfo storageInfo2 = mAppSearchImpl.getStorageInfoForDatabase("package", "db2");
-        assertThat(storageInfo2.getBlobSizeBytes()).isEqualTo(20 * 1024);
-        assertThat(storageInfo2.getBlobCount()).isEqualTo(1);
+        assertThat(storageInfo2.getBlobsSizeBytes()).isEqualTo(20 * 1024);
+        assertThat(storageInfo2.getBlobsCount()).isEqualTo(1);
     }
 
     @Test
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
index 390b420..6cb8f15 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
@@ -2497,8 +2497,8 @@
                 blobCount += blobStorageInfoProto.getNumBlobs();
             }
         }
-        storageInfoBuilder.setBlobCount(blobCount)
-                .setBlobSizeBytes(blobSizeBytes);
+        storageInfoBuilder.setBlobsCount(blobCount)
+                .setBlobsSizeBytes(blobSizeBytes);
     }
 
     /**
diff --git a/appsearch/appsearch-play-services-storage/build.gradle b/appsearch/appsearch-play-services-storage/build.gradle
index 3203f1a..2da9915 100644
--- a/appsearch/appsearch-play-services-storage/build.gradle
+++ b/appsearch/appsearch-play-services-storage/build.gradle
@@ -30,7 +30,7 @@
 
 dependencies {
     api(libs.jspecify)
-    implementation project(":appsearch:appsearch")
+    implementation(project(":appsearch:appsearch"))
     implementation("androidx.core:core:1.12.0")
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
     implementation("androidx.collection:collection:1.4.2")
diff --git a/appsearch/appsearch/api/1.1.0-beta01.txt b/appsearch/appsearch/api/1.1.0-beta01.txt
index 70b88f0..ee6ec95 100644
--- a/appsearch/appsearch/api/1.1.0-beta01.txt
+++ b/appsearch/appsearch/api/1.1.0-beta01.txt
@@ -962,8 +962,8 @@
   public final class StorageInfo {
     method public int getAliveDocumentsCount();
     method public int getAliveNamespacesCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobSizeBytes();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobsCount();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobsSizeBytes();
     method public long getSizeBytes();
   }
 
@@ -972,8 +972,8 @@
     method public androidx.appsearch.app.StorageInfo build();
     method public androidx.appsearch.app.StorageInfo.Builder setAliveDocumentsCount(int);
     method public androidx.appsearch.app.StorageInfo.Builder setAliveNamespacesCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobSizeBytes(long);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsCount(int);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsSizeBytes(long);
     method public androidx.appsearch.app.StorageInfo.Builder setSizeBytes(long);
   }
 
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index 70b88f0..ee6ec95 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -962,8 +962,8 @@
   public final class StorageInfo {
     method public int getAliveDocumentsCount();
     method public int getAliveNamespacesCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobSizeBytes();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobsCount();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobsSizeBytes();
     method public long getSizeBytes();
   }
 
@@ -972,8 +972,8 @@
     method public androidx.appsearch.app.StorageInfo build();
     method public androidx.appsearch.app.StorageInfo.Builder setAliveDocumentsCount(int);
     method public androidx.appsearch.app.StorageInfo.Builder setAliveNamespacesCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobSizeBytes(long);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsCount(int);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsSizeBytes(long);
     method public androidx.appsearch.app.StorageInfo.Builder setSizeBytes(long);
   }
 
diff --git a/appsearch/appsearch/api/restricted_1.1.0-beta01.txt b/appsearch/appsearch/api/restricted_1.1.0-beta01.txt
index 70b88f0..ee6ec95 100644
--- a/appsearch/appsearch/api/restricted_1.1.0-beta01.txt
+++ b/appsearch/appsearch/api/restricted_1.1.0-beta01.txt
@@ -962,8 +962,8 @@
   public final class StorageInfo {
     method public int getAliveDocumentsCount();
     method public int getAliveNamespacesCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobSizeBytes();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobsCount();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobsSizeBytes();
     method public long getSizeBytes();
   }
 
@@ -972,8 +972,8 @@
     method public androidx.appsearch.app.StorageInfo build();
     method public androidx.appsearch.app.StorageInfo.Builder setAliveDocumentsCount(int);
     method public androidx.appsearch.app.StorageInfo.Builder setAliveNamespacesCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobSizeBytes(long);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsCount(int);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsSizeBytes(long);
     method public androidx.appsearch.app.StorageInfo.Builder setSizeBytes(long);
   }
 
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index 70b88f0..ee6ec95 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -962,8 +962,8 @@
   public final class StorageInfo {
     method public int getAliveDocumentsCount();
     method public int getAliveNamespacesCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobCount();
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobSizeBytes();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public int getBlobsCount();
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public long getBlobsSizeBytes();
     method public long getSizeBytes();
   }
 
@@ -972,8 +972,8 @@
     method public androidx.appsearch.app.StorageInfo build();
     method public androidx.appsearch.app.StorageInfo.Builder setAliveDocumentsCount(int);
     method public androidx.appsearch.app.StorageInfo.Builder setAliveNamespacesCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobCount(int);
-    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobSizeBytes(long);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsCount(int);
+    method @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public androidx.appsearch.app.StorageInfo.Builder setBlobsSizeBytes(long);
     method public androidx.appsearch.app.StorageInfo.Builder setSizeBytes(long);
   }
 
diff --git a/appsearch/appsearch/build.gradle b/appsearch/appsearch/build.gradle
index f4f9c7c..bbf2bdd 100644
--- a/appsearch/appsearch/build.gradle
+++ b/appsearch/appsearch/build.gradle
@@ -46,14 +46,14 @@
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
     implementation("androidx.core:core:1.9.0")
 
-    annotationProcessor project(":appsearch:appsearch-compiler")
+    annotationProcessor(project(":appsearch:appsearch-compiler"))
 
-    androidTestAnnotationProcessor project(":appsearch:appsearch-compiler")
-    androidTestImplementation project(":appsearch:appsearch-builtin-types")
-    androidTestImplementation project(":appsearch:appsearch-local-storage")
-    androidTestImplementation project(":appsearch:appsearch-platform-storage")
-    androidTestImplementation project(":appsearch:appsearch-play-services-storage")
-    androidTestImplementation project(":appsearch:appsearch-test-util")
+    androidTestAnnotationProcessor(project(":appsearch:appsearch-compiler"))
+    androidTestImplementation(project(":appsearch:appsearch-builtin-types"))
+    androidTestImplementation(project(":appsearch:appsearch-local-storage"))
+    androidTestImplementation(project(":appsearch:appsearch-platform-storage"))
+    androidTestImplementation(project(":appsearch:appsearch-play-services-storage"))
+    androidTestImplementation(project(":appsearch:appsearch-test-util"))
     // Needed to check if PlayServicesAppSearch throws ApiException.
     androidTestImplementation("com.google.android.gms:play-services-basement:18.1.0", {
         exclude group: "androidx.core", module: "core"
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
index d5c1437..fd4cb7a 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchSessionBlobCtsTestBase.java
@@ -506,9 +506,9 @@
         writeResponse.close();
 
         StorageInfo after = mDb1.getStorageInfoAsync().get();
-        assertThat(after.getBlobCount()).isEqualTo(before.getBlobCount() + 2);
-        assertThat(after.getBlobSizeBytes()).isEqualTo(
-                before.getBlobSizeBytes() + mData1.length + mData2.length);
+        assertThat(after.getBlobsCount()).isEqualTo(before.getBlobsCount() + 2);
+        assertThat(after.getBlobsSizeBytes()).isEqualTo(
+                before.getBlobsSizeBytes() + mData1.length + mData2.length);
     }
 
     @Test
@@ -539,22 +539,22 @@
         writeResponse.close();
 
         StorageInfo after = mDb1.getStorageInfoAsync().get();
-        assertThat(after.getBlobCount()).isEqualTo(before.getBlobCount() + 2);
-        assertThat(after.getBlobSizeBytes()).isEqualTo(
-                before.getBlobSizeBytes() + mData1.length + mData2.length);
+        assertThat(after.getBlobsCount()).isEqualTo(before.getBlobsCount() + 2);
+        assertThat(after.getBlobsSizeBytes()).isEqualTo(
+                before.getBlobsSizeBytes() + mData1.length + mData2.length);
 
         // remove blob 1
         mDb1.removeBlobAsync(ImmutableSet.of(mHandle1)).get();
         StorageInfo afterRemove1 = mDb1.getStorageInfoAsync().get();
-        assertThat(afterRemove1.getBlobCount()).isEqualTo(before.getBlobCount() + 1);
-        assertThat(afterRemove1.getBlobSizeBytes()).isEqualTo(
-                before.getBlobSizeBytes() + mData2.length);
+        assertThat(afterRemove1.getBlobsCount()).isEqualTo(before.getBlobsCount() + 1);
+        assertThat(afterRemove1.getBlobsSizeBytes()).isEqualTo(
+                before.getBlobsSizeBytes() + mData2.length);
 
         // remove blob 2
         mDb1.removeBlobAsync(ImmutableSet.of(mHandle2)).get();
         StorageInfo afterRemove2 = mDb1.getStorageInfoAsync().get();
-        assertThat(afterRemove2.getBlobCount()).isEqualTo(before.getBlobCount());
-        assertThat(afterRemove2.getBlobSizeBytes()).isEqualTo(before.getBlobSizeBytes());
+        assertThat(afterRemove2.getBlobsCount()).isEqualTo(before.getBlobsCount());
+        assertThat(afterRemove2.getBlobsSizeBytes()).isEqualTo(before.getBlobsSizeBytes());
     }
 
     @Test
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/StorageInfoCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/StorageInfoCtsTest.java
index 0b16822..2cedd09 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/StorageInfoCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/StorageInfoCtsTest.java
@@ -60,12 +60,12 @@
     public void testBuildStorageInfo_withBlob() {
         StorageInfo storageInfo =
                 new StorageInfo.Builder()
-                        .setBlobSizeBytes(12L)
-                        .setBlobCount(20)
+                        .setBlobsSizeBytes(12L)
+                        .setBlobsCount(20)
                         .build();
 
-        assertThat(storageInfo.getBlobCount()).isEqualTo(20);
-        assertThat(storageInfo.getBlobSizeBytes()).isEqualTo(12L);
+        assertThat(storageInfo.getBlobsCount()).isEqualTo(20);
+        assertThat(storageInfo.getBlobsSizeBytes()).isEqualTo(12L);
     }
 
     @Test
@@ -73,7 +73,7 @@
     public void testBuildStorageInfo_withBlobDefaults() {
         StorageInfo storageInfo = new StorageInfo.Builder().build();
 
-        assertThat(storageInfo.getBlobCount()).isEqualTo(0);
-        assertThat(storageInfo.getBlobSizeBytes()).isEqualTo(0L);
+        assertThat(storageInfo.getBlobsCount()).isEqualTo(0);
+        assertThat(storageInfo.getBlobsSizeBytes()).isEqualTo(0L);
     }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/StorageInfo.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/StorageInfo.java
index d3d0988..70acc62 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/app/StorageInfo.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/StorageInfo.java
@@ -46,24 +46,24 @@
     @Field(id = 3, getter = "getAliveNamespacesCount")
     private int mAliveNamespacesCount;
 
-    @Field(id = 4, getter = "getBlobSizeBytes")
-    private long mBlobSizeBytes;
+    @Field(id = 4, getter = "getBlobsSizeBytes")
+    private long mBlobsSizeBytes;
 
-    @Field(id = 5, getter = "getBlobCount")
-    private int mBlobCount;
+    @Field(id = 5, getter = "getBlobsCount")
+    private int mBlobsCount;
 
     @Constructor
     StorageInfo(
             @Param(id = 1) long sizeBytes,
             @Param(id = 2) int aliveDocumentsCount,
             @Param(id = 3) int aliveNamespacesCount,
-            @Param(id = 4) long blobSizeBytes,
-            @Param(id = 5) int blobCount) {
+            @Param(id = 4) long blobsSizeBytes,
+            @Param(id = 5) int blobsCount) {
         mSizeBytes = sizeBytes;
         mAliveDocumentsCount = aliveDocumentsCount;
         mAliveNamespacesCount = aliveNamespacesCount;
-        mBlobSizeBytes = blobSizeBytes;
-        mBlobCount = blobCount;
+        mBlobsSizeBytes = blobsSizeBytes;
+        mBlobsCount = blobsCount;
     }
 
     /** Returns the estimated size of the session's database in bytes. */
@@ -101,8 +101,8 @@
      */
     @FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
     @ExperimentalAppSearchApi
-    public long getBlobSizeBytes() {
-        return mBlobSizeBytes;
+    public long getBlobsSizeBytes() {
+        return mBlobsSizeBytes;
     }
 
     /**
@@ -114,8 +114,8 @@
      */
     @FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
     @ExperimentalAppSearchApi
-    public int getBlobCount() {
-        return mBlobCount;
+    public int getBlobsCount() {
+        return mBlobsCount;
     }
 
     /** Builder for {@link StorageInfo} objects. */
@@ -123,8 +123,8 @@
         private long mSizeBytes;
         private int mAliveDocumentsCount;
         private int mAliveNamespacesCount;
-        private long mBlobSizeBytes;
-        private int mBlobCount;
+        private long mBlobsSizeBytes;
+        private int mBlobsCount;
 
         /** Sets the size in bytes. */
         @CanIgnoreReturnValue
@@ -151,8 +151,8 @@
         @CanIgnoreReturnValue
         @FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
         @ExperimentalAppSearchApi
-        public StorageInfo.@NonNull Builder setBlobSizeBytes(long blobSizeBytes) {
-            mBlobSizeBytes = blobSizeBytes;
+        public StorageInfo.@NonNull Builder setBlobsSizeBytes(long blobsSizeBytes) {
+            mBlobsSizeBytes = blobsSizeBytes;
             return this;
         }
 
@@ -160,15 +160,15 @@
         @CanIgnoreReturnValue
         @FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
         @ExperimentalAppSearchApi
-        public StorageInfo.@NonNull Builder setBlobCount(int blobCount) {
-            mBlobCount = blobCount;
+        public StorageInfo.@NonNull Builder setBlobsCount(int blobsCount) {
+            mBlobsCount = blobsCount;
             return this;
         }
 
         /** Builds a {@link StorageInfo} object. */
         public @NonNull StorageInfo build() {
             return new StorageInfo(mSizeBytes, mAliveDocumentsCount, mAliveNamespacesCount,
-                    mBlobSizeBytes,  mBlobCount);
+                    mBlobsSizeBytes, mBlobsCount);
         }
     }
 
diff --git a/benchmark/benchmark-common/api/current.txt b/benchmark/benchmark-common/api/current.txt
index 71e919b..f1f1bfe 100644
--- a/benchmark/benchmark-common/api/current.txt
+++ b/benchmark/benchmark-common/api/current.txt
@@ -126,30 +126,13 @@
     property public final String text;
   }
 
-  @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public final class PerfettoTrace {
-    ctor public PerfettoTrace(String path);
-    method public String getPath();
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    property public final String path;
-    field public static final androidx.benchmark.perfetto.PerfettoTrace.Companion Companion;
-  }
+}
 
-  public static final class PerfettoTrace.Companion {
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, kotlin.jvm.functions.Function0<kotlin.Unit> block);
+package androidx.benchmark.traceprocessor {
+
+  public final class PerfettoTraceKt {
+    method @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public static void record(androidx.benchmark.traceprocessor.PerfettoTrace.Companion, String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
+    method @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public static void record(androidx.benchmark.traceprocessor.PerfettoTrace.Companion, String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
   }
 
 }
diff --git a/benchmark/benchmark-common/api/restricted_current.txt b/benchmark/benchmark-common/api/restricted_current.txt
index ce0b56a..7271b61 100644
--- a/benchmark/benchmark-common/api/restricted_current.txt
+++ b/benchmark/benchmark-common/api/restricted_current.txt
@@ -129,30 +129,13 @@
     property public final String text;
   }
 
-  @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public final class PerfettoTrace {
-    ctor public PerfettoTrace(String path);
-    method public String getPath();
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public static void record(String fileLabel, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    property public final String path;
-    field public static final androidx.benchmark.perfetto.PerfettoTrace.Companion Companion;
-  }
+}
 
-  public static final class PerfettoTrace.Companion {
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, optional java.util.List<java.lang.String> appTagPackages, kotlin.jvm.functions.Function0<kotlin.Unit> block);
-    method public void record(String fileLabel, kotlin.jvm.functions.Function0<kotlin.Unit> block);
+package androidx.benchmark.traceprocessor {
+
+  public final class PerfettoTraceKt {
+    method @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public static void record(androidx.benchmark.traceprocessor.PerfettoTrace.Companion, String fileLabel, androidx.benchmark.perfetto.PerfettoConfig config, optional String highlightPackage, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
+    method @SuppressCompatibility @RequiresApi(23) @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public static void record(androidx.benchmark.traceprocessor.PerfettoTrace.Companion, String fileLabel, optional java.util.List<java.lang.String> appTagPackages, optional String? userspaceTracingPackage, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback, kotlin.jvm.functions.Function0<kotlin.Unit> block);
   }
 
 }
diff --git a/benchmark/benchmark-common/build.gradle b/benchmark/benchmark-common/build.gradle
index e00287e..d1537e3 100644
--- a/benchmark/benchmark-common/build.gradle
+++ b/benchmark/benchmark-common/build.gradle
@@ -79,6 +79,7 @@
     implementation(libs.kotlinStdlib)
     api("androidx.annotation:annotation:1.8.1")
     api("androidx.annotation:annotation-experimental:1.4.1")
+    api(project(":benchmark:benchmark-traceprocessor"))
     implementation("androidx.tracing:tracing-ktx:1.0.0")
     implementation("androidx.tracing:tracing-perfetto-handshake:1.0.0")
     implementation("androidx.test:monitor:1.6.1")
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/PerfettoTraceTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/PerfettoTraceTest.kt
index 6068fa7b..674a181 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/PerfettoTraceTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/PerfettoTraceTest.kt
@@ -19,9 +19,10 @@
 import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
 import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTrace
 import androidx.benchmark.perfetto.perfettoConfig
 import androidx.benchmark.perfetto.validateAndEncode
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.record
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ShellTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ShellTest.kt
index 1ea94ba..2661c62 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ShellTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ShellTest.kt
@@ -473,6 +473,55 @@
         )
     }
 
+    @Test
+    fun psLineContainsProcess() {
+        // shell executables
+        "root      10065 10061 14848  3932  poll_sched 7bcaf1fc8c S /data/local/tmp/tracebox"
+            .apply {
+                assertTrue(Shell.psLineContainsProcess(this, "tracebox"))
+                assertFalse(Shell.psLineContainsProcess(this, "tracebo"))
+            }
+
+        "root      10109 1     11552  1140  poll_sched 78c86eac8c S ./tracebox"
+            .apply {
+                assertTrue(Shell.psLineContainsProcess(this, "tracebox"))
+                assertFalse(Shell.psLineContainsProcess(this, "tracebo"))
+            }
+
+        // app
+        "u0_a140       9253  9778   15120128 139856 do_epoll_wait       0 S example.app"
+            .apply {
+                assertTrue(Shell.psLineContainsProcess(this, "example.app"))
+                assertFalse(Shell.psLineContainsProcess(this, "example.ap"))
+            }
+        // app subprocess
+        "u0_a140       9253  9778   15120128 139856 do_epoll_wait       0 S example.app:ui"
+            .apply {
+                assertTrue(Shell.psLineContainsProcess(this, "example.app:ui"))
+                assertTrue(Shell.psLineContainsProcess(this, "example.app"))
+                assertFalse(Shell.psLineContainsProcess(this, "example.ap"))
+            }
+    }
+
+    @Test
+    fun fullProcessNameMatchesProcess() {
+        // shell executables
+        assertTrue(Shell.fullProcessNameMatchesProcess("/data/local/tmp/tracebox", "tracebox"))
+        assertFalse(Shell.fullProcessNameMatchesProcess("/data/local/tmp/tracebox", "tracebo"))
+
+        assertTrue(Shell.fullProcessNameMatchesProcess("./tracebox", "tracebox"))
+        assertFalse(Shell.fullProcessNameMatchesProcess("./tracebox", "tracebo"))
+
+        // app
+        assertTrue(Shell.fullProcessNameMatchesProcess("example.app", "example.app"))
+        assertFalse(Shell.fullProcessNameMatchesProcess("example.app", "example.ap"))
+
+        // app subprocess
+        assertTrue(Shell.fullProcessNameMatchesProcess("example.app:ui", "example.app:ui"))
+        assertTrue(Shell.fullProcessNameMatchesProcess("example.app:ui", "example.app"))
+        assertFalse(Shell.fullProcessNameMatchesProcess("example.app:ui", "example.ap"))
+    }
+
     private fun pidof(packageName: String): Int? {
         return Shell.getPidsForProcess(packageName).firstOrNull()
     }
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
index be459d07..c352a21 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
@@ -92,7 +92,7 @@
     internal val requireAot: Boolean
     internal val requireJitDisabledIfRooted: Boolean
     val throwOnMainThreadMeasureRepeated: Boolean // non-internal, used in BenchmarkRule
-    val runOnMainDeadlineSeconds: Long // non-internal, used in BenchmarkRule
+    val measureRepeatedOnMainThrowOnDeadline: Boolean // non-internal, used in BenchmarkRule
 
     internal var error: String? = null
     internal val additionalTestOutputDir: String?
@@ -332,11 +332,10 @@
         dropShadersThrowOnFailure =
             arguments.getBenchmarkArgument("dropShaders.throwOnFailure")?.toBoolean() ?: true
 
-        // very relaxed default to start, ideally this would be less than 5 (ANR timeout),
-        // but configurability should help experimenting / narrowing over time
-        runOnMainDeadlineSeconds =
-            arguments.getBenchmarkArgument("runOnMainDeadlineSeconds")?.toLong() ?: 30
-        Log.d(BenchmarkState.TAG, "runOnMainDeadlineSeconds $runOnMainDeadlineSeconds")
+        measureRepeatedOnMainThrowOnDeadline =
+            arguments
+                .getBenchmarkArgument("measureRepeatedOnMainThread.throwOnDeadline")
+                ?.toBoolean() ?: true
 
         requireAot = arguments.getBenchmarkArgument("requireAot")?.toBoolean() ?: false
         requireJitDisabledIfRooted =
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/CpuInfo.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/CpuInfo.kt
index e09eebe..3fab906 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/CpuInfo.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/CpuInfo.kt
@@ -16,12 +16,10 @@
 
 package androidx.benchmark
 
-import android.annotation.SuppressLint
 import android.util.Log
 import java.io.File
 import java.io.IOException
 
-@SuppressLint("ClassVerificationFailure")
 internal object CpuInfo {
     private const val TAG = "Benchmark"
 
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Shell.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Shell.kt
index 500f13f..5e81a00 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Shell.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Shell.kt
@@ -59,19 +59,24 @@
      * As this function is also used for package names (which never have a leading `/`), we simply
      * check for either.
      */
-    private fun psLineContainsProcess(psOutputLine: String, processName: String): Boolean {
-        return psOutputLine.endsWith(" $processName") || psOutputLine.endsWith("/$processName")
+    internal fun psLineContainsProcess(psOutputLine: String, processName: String): Boolean {
+        val processLabel = psOutputLine.substringAfterLast(" ")
+        return processLabel == processName || // exact match
+            processLabel.startsWith("$processName:") || // app subprocess
+            processLabel.endsWith("/$processName") // executable with relative path
     }
 
     /**
      * Equivalent of [psLineContainsProcess], but to be used with full process name string (e.g.
      * from pgrep)
      */
-    private fun fullProcessNameMatchesProcess(
+    internal fun fullProcessNameMatchesProcess(
         fullProcessName: String,
         processName: String
     ): Boolean {
-        return fullProcessName == processName || fullProcessName.endsWith("/$processName")
+        return fullProcessName == processName || // exact match
+            fullProcessName.startsWith("$processName:") || // app subprocess
+            fullProcessName.endsWith("/$processName") // executable with relative path
     }
 
     fun connectUiAutomation() {
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoTrace.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoTrace.kt
deleted file mode 100644
index e68e4c5..0000000
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/perfetto/PerfettoTrace.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright 2022 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 androidx.benchmark.perfetto
-
-import androidx.annotation.RequiresApi
-import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig.InitialProcessState
-import androidx.test.platform.app.InstrumentationRegistry
-import java.io.File
-
-@RequiresApi(23) // should match PerfettoHelper.MIN_SDK_VERSION
-@ExperimentalPerfettoCaptureApi
-class PerfettoTrace(
-    /**
-     * Absolute file path of the trace.
-     *
-     * Note that the trace is not guaranteed to be placed into an app-accessible directory, and may
-     * require shell commands to access.
-     */
-    val path: String
-) {
-    companion object {
-        /**
-         * Record a Perfetto System Trace for the specified [block].
-         *
-         * ```
-         * PerfettoTrace.record("myTrace") {
-         *     // content in here is traced to myTrace_<timestamp>.perfetto_trace
-         * }
-         * ```
-         *
-         * Reentrant Perfetto trace capture is not supported, so this API may not be combined with
-         * `BenchmarkRule`, `MacrobenchmarkRule`, or `PerfettoTraceRule`.
-         *
-         * If the block throws, the trace is still captured and passed to [traceCallback].
-         */
-        @JvmStatic
-        @JvmOverloads
-        fun record(
-            /**
-             * Output trace file names are labelled `<fileLabel>_<timestamp>.perfetto_trace`
-             *
-             * This timestamp is used for uniqueness when trace files are pulled automatically to
-             * Studio.
-             */
-            fileLabel: String,
-            /**
-             * Target process to trace with app tag (enables android.os.Trace / androidx.Trace).
-             *
-             * By default, traces this process.
-             */
-            appTagPackages: List<String> =
-                listOf(InstrumentationRegistry.getInstrumentation().targetContext.packageName),
-            /**
-             * Process to trace with userspace tracing, i.e. `androidx.tracing:tracing-perfetto`,
-             * ignored below API 30.
-             *
-             * This tracing is lower overhead than standard `android.os.Trace` tracepoints, but is
-             * currently experimental.
-             */
-            userspaceTracingPackage: String? = null,
-            /**
-             * Callback for trace capture.
-             *
-             * This callback allows you to process the trace even if the block throws, e.g. during a
-             * test failure.
-             */
-            traceCallback: ((PerfettoTrace) -> Unit)? = null,
-            /** Block to be traced. */
-            block: () -> Unit
-        ) =
-            record(
-                fileLabel = fileLabel,
-                config =
-                    PerfettoConfig.Benchmark(
-                        appTagPackages = appTagPackages,
-                        useStackSamplingConfig = true
-                    ),
-                userspaceTracingPackage = userspaceTracingPackage,
-                traceCallback = traceCallback,
-                block = block
-            )
-
-        /**
-         * Record a Perfetto System Trace for the specified [block], with a fully custom Perfetto
-         * config, either text or binary.
-         *
-         * ```
-         * PerfettoTrace.record("myTrace", config = """...""") {
-         *     // content in here is traced to myTrace_<timestamp>.perfetto_trace
-         * }
-         * ```
-         *
-         * Reentrant Perfetto trace capture is not supported, so this API may not be combined with
-         * `BenchmarkRule`, `MacrobenchmarkRule`, or `PerfettoTraceRule`.
-         *
-         * If the block throws, the trace is still captured and passed to [traceCallback].
-         */
-        @JvmStatic
-        @JvmOverloads
-        fun record(
-            /**
-             * Output trace file names are labelled `<fileLabel>_<timestamp>.perfetto_trace`
-             *
-             * This timestamp is used for uniqueness when trace files are pulled automatically to
-             * Studio.
-             */
-            fileLabel: String,
-            /** Trace recording configuration. */
-            config: PerfettoConfig,
-            /**
-             * Process to emphasize in the tracing UI.
-             *
-             * Used to emphasize the target process, e.g. by pre-populating Studio trace viewer
-             * process selection.
-             *
-             * Defaults to the test's target process. Note that for self-instrumenting tests that
-             * measure another app, you must pass that target app package.
-             */
-            highlightPackage: String =
-                InstrumentationRegistry.getInstrumentation().targetContext.packageName,
-            /**
-             * Process to trace with userspace tracing, i.e. `androidx.tracing:tracing-perfetto`,
-             * ignored below API 30.
-             *
-             * This tracing is lower overhead than standard `android.os.Trace` tracepoints, but is
-             * currently experimental.
-             */
-            userspaceTracingPackage: String? = null,
-            /**
-             * Callback for trace capture.
-             *
-             * This callback allows you to process the trace even if the block throws, e.g. during a
-             * test failure.
-             */
-            traceCallback: ((PerfettoTrace) -> Unit)? = null,
-            /** Block to be traced. */
-            block: () -> Unit
-        ) {
-            PerfettoCaptureWrapper()
-                .record(
-                    fileLabel = fileLabel,
-                    config,
-                    perfettoSdkConfig =
-                        userspaceTracingPackage?.let {
-                            PerfettoCapture.PerfettoSdkConfig(it, InitialProcessState.Unknown)
-                        },
-                    traceCallback = { path ->
-                        File(path)
-                            .appendUiState(
-                                UiState(
-                                    timelineStart = null,
-                                    timelineEnd = null,
-                                    highlightPackage = highlightPackage
-                                )
-                            )
-                        traceCallback?.invoke(PerfettoTrace(path))
-                    },
-                    block = block
-                )
-        }
-    }
-}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/traceprocessor/PerfettoTrace.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/traceprocessor/PerfettoTrace.kt
new file mode 100644
index 0000000..c0b60c7
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/traceprocessor/PerfettoTrace.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.annotation.RequiresApi
+import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
+import androidx.benchmark.perfetto.PerfettoCapture
+import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig.InitialProcessState
+import androidx.benchmark.perfetto.PerfettoCaptureWrapper
+import androidx.benchmark.perfetto.PerfettoConfig
+import androidx.benchmark.perfetto.UiState
+import androidx.benchmark.perfetto.appendUiState
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.File
+
+/**
+ * Record a Perfetto System Trace for the specified [block].
+ *
+ * ```
+ * PerfettoTrace.record("myTrace") {
+ *     // content in here is traced to myTrace_<timestamp>.perfetto_trace
+ * }
+ * ```
+ *
+ * Reentrant Perfetto trace capture is not supported, so this API may not be combined with
+ * `BenchmarkRule`, `MacrobenchmarkRule`, or `PerfettoTraceRule`.
+ *
+ * If the block throws, the trace is still captured and passed to [traceCallback].
+ */
+@RequiresApi(23)
+@ExperimentalPerfettoCaptureApi
+fun PerfettoTrace.Companion.record(
+    /**
+     * Output trace file names are labelled `<fileLabel>_<timestamp>.perfetto_trace`
+     *
+     * This timestamp is used for uniqueness when trace files are pulled automatically to Studio.
+     */
+    fileLabel: String,
+    /**
+     * Target process to trace with app tag (enables android.os.Trace / androidx.Trace).
+     *
+     * By default, traces this process.
+     */
+    appTagPackages: List<String> =
+        listOf(InstrumentationRegistry.getInstrumentation().targetContext.packageName),
+    /**
+     * Process to trace with userspace tracing, i.e. `androidx.tracing:tracing-perfetto`, ignored
+     * below API 30.
+     *
+     * This tracing is lower overhead than standard `android.os.Trace` tracepoints, but is currently
+     * experimental.
+     */
+    userspaceTracingPackage: String? = null,
+    /**
+     * Callback for trace capture.
+     *
+     * This callback allows you to process the trace even if the block throws, e.g. during a test
+     * failure.
+     */
+    traceCallback: ((PerfettoTrace) -> Unit)? = null,
+    /** Block to be traced. */
+    block: () -> Unit
+) =
+    record(
+        fileLabel = fileLabel,
+        config =
+            PerfettoConfig.Benchmark(
+                appTagPackages = appTagPackages,
+                useStackSamplingConfig = true
+            ),
+        userspaceTracingPackage = userspaceTracingPackage,
+        traceCallback = traceCallback,
+        block = block
+    )
+
+/**
+ * Record a Perfetto System Trace for the specified [block], with a fully custom Perfetto config,
+ * either text or binary.
+ *
+ * ```
+ * PerfettoTrace.record("myTrace", config = """...""") {
+ *     // content in here is traced to myTrace_<timestamp>.perfetto_trace
+ * }
+ * ```
+ *
+ * Reentrant Perfetto trace capture is not supported, so this API may not be combined with
+ * `BenchmarkRule`, `MacrobenchmarkRule`, or `PerfettoTraceRule`.
+ *
+ * If the block throws, the trace is still captured and passed to [traceCallback].
+ */
+@RequiresApi(23)
+@ExperimentalPerfettoCaptureApi
+fun PerfettoTrace.Companion.record(
+    /**
+     * Output trace file names are labelled `<fileLabel>_<timestamp>.perfetto_trace`
+     *
+     * This timestamp is used for uniqueness when trace files are pulled automatically to Studio.
+     */
+    fileLabel: String,
+    /** Trace recording configuration. */
+    config: PerfettoConfig,
+    /**
+     * Process to emphasize in the tracing UI.
+     *
+     * Used to emphasize the target process, e.g. by pre-populating Studio trace viewer process
+     * selection.
+     *
+     * Defaults to the test's target process. Note that for self-instrumenting tests that measure
+     * another app, you must pass that target app package.
+     */
+    highlightPackage: String =
+        InstrumentationRegistry.getInstrumentation().targetContext.packageName,
+    /**
+     * Process to trace with userspace tracing, i.e. `androidx.tracing:tracing-perfetto`, ignored
+     * below API 30.
+     *
+     * This tracing is lower overhead than standard `android.os.Trace` tracepoints, but is currently
+     * experimental.
+     */
+    userspaceTracingPackage: String? = null,
+    /**
+     * Callback for trace capture.
+     *
+     * This callback allows you to process the trace even if the block throws, e.g. during a test
+     * failure.
+     */
+    traceCallback: ((PerfettoTrace) -> Unit)? = null,
+    /** Block to be traced. */
+    block: () -> Unit
+) {
+    PerfettoCaptureWrapper()
+        .record(
+            fileLabel = fileLabel,
+            config,
+            perfettoSdkConfig =
+                userspaceTracingPackage?.let {
+                    PerfettoCapture.PerfettoSdkConfig(it, InitialProcessState.Unknown)
+                },
+            traceCallback = { path ->
+                File(path)
+                    .appendUiState(
+                        UiState(
+                            timelineStart = null,
+                            timelineEnd = null,
+                            highlightPackage = highlightPackage
+                        )
+                    )
+                traceCallback?.invoke(PerfettoTrace(path))
+            },
+            block = block
+        )
+}
diff --git a/benchmark/benchmark-darwin-gradle-plugin/build.gradle b/benchmark/benchmark-darwin-gradle-plugin/build.gradle
index 22ffc9d..1566464 100644
--- a/benchmark/benchmark-darwin-gradle-plugin/build.gradle
+++ b/benchmark/benchmark-darwin-gradle-plugin/build.gradle
@@ -50,7 +50,6 @@
 
 androidx {
     name = "Benchmarks - Darwin Gradle Plugin"
-    publish = Publish.SNAPSHOT_ONLY
     type = LibraryType.GRADLE_PLUGIN
     inceptionYear = "2022"
     description = "AndroidX Benchmarks - Darwin Gradle Plugin"
diff --git a/benchmark/benchmark-junit4/api/current.txt b/benchmark/benchmark-junit4/api/current.txt
index a1baf9b..9cf3391 100644
--- a/benchmark/benchmark-junit4/api/current.txt
+++ b/benchmark/benchmark-junit4/api/current.txt
@@ -23,14 +23,14 @@
 
   @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public final class PerfettoTraceRule implements org.junit.rules.TestRule {
     ctor public PerfettoTraceRule();
-    ctor public PerfettoTraceRule(optional boolean enableAppTagTracing, optional boolean enableUserspaceTracing, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback);
+    ctor public PerfettoTraceRule(optional boolean enableAppTagTracing, optional boolean enableUserspaceTracing, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback);
     method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description);
     method public boolean getEnableAppTagTracing();
     method public boolean getEnableUserspaceTracing();
-    method public kotlin.jvm.functions.Function1<androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? getTraceCallback();
+    method public kotlin.jvm.functions.Function1<androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? getTraceCallback();
     property public final boolean enableAppTagTracing;
     property public final boolean enableUserspaceTracing;
-    property public final kotlin.jvm.functions.Function1<androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback;
+    property public final kotlin.jvm.functions.Function1<androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback;
   }
 
 }
diff --git a/benchmark/benchmark-junit4/api/restricted_current.txt b/benchmark/benchmark-junit4/api/restricted_current.txt
index 60b3cc5..13794ef 100644
--- a/benchmark/benchmark-junit4/api/restricted_current.txt
+++ b/benchmark/benchmark-junit4/api/restricted_current.txt
@@ -24,14 +24,14 @@
 
   @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi public final class PerfettoTraceRule implements org.junit.rules.TestRule {
     ctor public PerfettoTraceRule();
-    ctor public PerfettoTraceRule(optional boolean enableAppTagTracing, optional boolean enableUserspaceTracing, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback);
+    ctor public PerfettoTraceRule(optional boolean enableAppTagTracing, optional boolean enableUserspaceTracing, optional kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback);
     method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement base, org.junit.runner.Description description);
     method public boolean getEnableAppTagTracing();
     method public boolean getEnableUserspaceTracing();
-    method public kotlin.jvm.functions.Function1<androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? getTraceCallback();
+    method public kotlin.jvm.functions.Function1<androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? getTraceCallback();
     property public final boolean enableAppTagTracing;
     property public final boolean enableUserspaceTracing;
-    property public final kotlin.jvm.functions.Function1<androidx.benchmark.perfetto.PerfettoTrace,kotlin.Unit>? traceCallback;
+    property public final kotlin.jvm.functions.Function1<androidx.benchmark.traceprocessor.PerfettoTrace,kotlin.Unit>? traceCallback;
   }
 
 }
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
index 616329a..b1afdf0 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
@@ -323,7 +323,9 @@
             val localScope = scope
 
             val initialTimeNs = System.nanoTime()
+            // we try to stop next measurement after soft deadline...
             val softDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(2)
+            // ... and throw if took longer than hard deadline
             val hardDeadlineNs = initialTimeNs + TimeUnit.SECONDS.toNanos(10)
             var timeNs: Long = 0
 
@@ -355,7 +357,7 @@
                 resumeScheduled = true
                 localState.pauseTiming()
 
-                if (timeNs > hardDeadlineNs) {
+                if (timeNs > hardDeadlineNs && Arguments.measureRepeatedOnMainThrowOnDeadline) {
                     localState.cleanupBeforeThrow()
                     val overrunInSec = (timeNs - hardDeadlineNs) / 1_000_000_000.0
                     throw IllegalStateException(
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoTraceRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoTraceRule.kt
index dc6cc23f..c4e67a5 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoTraceRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/PerfettoTraceRule.kt
@@ -20,7 +20,8 @@
 import androidx.benchmark.InstrumentationResults
 import androidx.benchmark.Profiler
 import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
-import androidx.benchmark.perfetto.PerfettoTrace
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.record
 import androidx.test.platform.app.InstrumentationRegistry
 import org.junit.rules.TestRule
 import org.junit.runner.Description
diff --git a/benchmark/benchmark-macro-junit4/build.gradle b/benchmark/benchmark-macro-junit4/build.gradle
index ac5de85..921cb59 100644
--- a/benchmark/benchmark-macro-junit4/build.gradle
+++ b/benchmark/benchmark-macro-junit4/build.gradle
@@ -77,7 +77,6 @@
                 "-opt-in=androidx.benchmark.ExperimentalBenchmarkConfigApi",
                 "-opt-in=androidx.benchmark.macro.ExperimentalMetricApi",
                 "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi",
-                "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi",
         ]
     }
 }
diff --git a/benchmark/benchmark-macro/api/current.ignore b/benchmark/benchmark-macro/api/current.ignore
new file mode 100644
index 0000000..8799e6e
--- /dev/null
+++ b/benchmark/benchmark-macro/api/current.ignore
@@ -0,0 +1,3 @@
+// Baseline format: 1.0
+RemovedPackage: androidx.benchmark.perfetto:
+    Removed package androidx.benchmark.perfetto
diff --git a/benchmark/benchmark-macro/api/current.txt b/benchmark/benchmark-macro/api/current.txt
index 7bcd26d..6e7ee41 100644
--- a/benchmark/benchmark-macro/api/current.txt
+++ b/benchmark/benchmark-macro/api/current.txt
@@ -86,12 +86,12 @@
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class MemoryCountersMetric extends androidx.benchmark.macro.TraceMetric {
     ctor public MemoryCountersMetric();
-    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
   }
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class MemoryUsageMetric extends androidx.benchmark.macro.TraceMetric {
     ctor public MemoryUsageMetric(androidx.benchmark.macro.MemoryUsageMetric.Mode mode, optional java.util.List<? extends androidx.benchmark.macro.MemoryUsageMetric.SubMetric> subMetrics);
-    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
   }
 
   public enum MemoryUsageMetric.Mode {
@@ -215,7 +215,12 @@
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public abstract class TraceMetric extends androidx.benchmark.macro.Metric {
     ctor public TraceMetric();
-    method public abstract java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public abstract java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
+  }
+
+  public final class TraceProcessorExtensionsKt {
+    method public static <T> T runServer(androidx.benchmark.traceprocessor.TraceProcessor.Companion, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+    method public static <T> T runServer(androidx.benchmark.traceprocessor.TraceProcessor.Companion, long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
   }
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class TraceSectionMetric extends androidx.benchmark.macro.Metric {
@@ -254,47 +259,3 @@
 
 }
 
-package androidx.benchmark.perfetto {
-
-  @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface ExperimentalPerfettoTraceProcessorApi {
-  }
-
-  @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public final class PerfettoTraceProcessor {
-    ctor public PerfettoTraceProcessor();
-    method public <T> T loadTrace(androidx.benchmark.perfetto.PerfettoTrace trace, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor.Session,? extends T> block);
-    method public static <T> T runServer(kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    method public static <T> T runServer(long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    field public static final androidx.benchmark.perfetto.PerfettoTraceProcessor.Companion Companion;
-  }
-
-  public static final class PerfettoTraceProcessor.Companion {
-    method public <T> T runServer(kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    method public static <T> T runServer(long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-  }
-
-  public static final class PerfettoTraceProcessor.Session {
-    method public kotlin.sequences.Sequence<androidx.benchmark.perfetto.Row> query(String query);
-    method public String queryMetricsJson(java.util.List<java.lang.String> metrics);
-    method public byte[] queryMetricsProtoBinary(java.util.List<java.lang.String> metrics);
-    method public String queryMetricsProtoText(java.util.List<java.lang.String> metrics);
-    method public byte[] rawQuery(@org.intellij.lang.annotations.Language("sql") String query);
-  }
-
-  @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public final class Row implements kotlin.jvm.internal.markers.KMappedMarker java.util.Map<java.lang.String,java.lang.Object?> {
-    ctor public Row(java.util.Map<java.lang.String,? extends java.lang.Object?> map);
-    method public byte[] bytes(String columnName);
-    method public double double(String columnName);
-    method public long long(String columnName);
-    method public byte[]? nullableBytes(String columnName);
-    method public Double? nullableDouble(String columnName);
-    method public Long? nullableLong(String columnName);
-    method public String? nullableString(String columnName);
-    method public String string(String columnName);
-  }
-
-  public final class RowKt {
-    method @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public static androidx.benchmark.perfetto.Row rowOf(kotlin.Pair<java.lang.String,? extends java.lang.Object?>... pairs);
-  }
-
-}
-
diff --git a/benchmark/benchmark-macro/api/restricted_current.ignore b/benchmark/benchmark-macro/api/restricted_current.ignore
new file mode 100644
index 0000000..8799e6e
--- /dev/null
+++ b/benchmark/benchmark-macro/api/restricted_current.ignore
@@ -0,0 +1,3 @@
+// Baseline format: 1.0
+RemovedPackage: androidx.benchmark.perfetto:
+    Removed package androidx.benchmark.perfetto
diff --git a/benchmark/benchmark-macro/api/restricted_current.txt b/benchmark/benchmark-macro/api/restricted_current.txt
index adfb9b2..e1bc0af 100644
--- a/benchmark/benchmark-macro/api/restricted_current.txt
+++ b/benchmark/benchmark-macro/api/restricted_current.txt
@@ -99,12 +99,12 @@
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class MemoryCountersMetric extends androidx.benchmark.macro.TraceMetric {
     ctor public MemoryCountersMetric();
-    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
   }
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class MemoryUsageMetric extends androidx.benchmark.macro.TraceMetric {
     ctor public MemoryUsageMetric(androidx.benchmark.macro.MemoryUsageMetric.Mode mode, optional java.util.List<? extends androidx.benchmark.macro.MemoryUsageMetric.SubMetric> subMetrics);
-    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
   }
 
   public enum MemoryUsageMetric.Mode {
@@ -237,7 +237,12 @@
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public abstract class TraceMetric extends androidx.benchmark.macro.Metric {
     ctor public TraceMetric();
-    method public abstract java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.perfetto.PerfettoTraceProcessor.Session traceSession);
+    method public abstract java.util.List<androidx.benchmark.macro.Metric.Measurement> getMeasurements(androidx.benchmark.macro.Metric.CaptureInfo captureInfo, androidx.benchmark.traceprocessor.TraceProcessor.Session traceSession);
+  }
+
+  public final class TraceProcessorExtensionsKt {
+    method public static <T> T runServer(androidx.benchmark.traceprocessor.TraceProcessor.Companion, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+    method public static <T> T runServer(androidx.benchmark.traceprocessor.TraceProcessor.Companion, long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
   }
 
   @SuppressCompatibility @androidx.benchmark.macro.ExperimentalMetricApi public final class TraceSectionMetric extends androidx.benchmark.macro.Metric {
@@ -276,47 +281,3 @@
 
 }
 
-package androidx.benchmark.perfetto {
-
-  @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface ExperimentalPerfettoTraceProcessorApi {
-  }
-
-  @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public final class PerfettoTraceProcessor {
-    ctor public PerfettoTraceProcessor();
-    method public <T> T loadTrace(androidx.benchmark.perfetto.PerfettoTrace trace, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor.Session,? extends T> block);
-    method public static <T> T runServer(kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    method public static <T> T runServer(long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    field public static final androidx.benchmark.perfetto.PerfettoTraceProcessor.Companion Companion;
-  }
-
-  public static final class PerfettoTraceProcessor.Companion {
-    method public <T> T runServer(kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-    method public static <T> T runServer(long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.perfetto.PerfettoTraceProcessor,? extends T> block);
-  }
-
-  public static final class PerfettoTraceProcessor.Session {
-    method public kotlin.sequences.Sequence<androidx.benchmark.perfetto.Row> query(String query);
-    method public String queryMetricsJson(java.util.List<java.lang.String> metrics);
-    method public byte[] queryMetricsProtoBinary(java.util.List<java.lang.String> metrics);
-    method public String queryMetricsProtoText(java.util.List<java.lang.String> metrics);
-    method public byte[] rawQuery(@org.intellij.lang.annotations.Language("sql") String query);
-  }
-
-  @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public final class Row implements kotlin.jvm.internal.markers.KMappedMarker java.util.Map<java.lang.String,java.lang.Object?> {
-    ctor public Row(java.util.Map<java.lang.String,? extends java.lang.Object?> map);
-    method public byte[] bytes(String columnName);
-    method public double double(String columnName);
-    method public long long(String columnName);
-    method public byte[]? nullableBytes(String columnName);
-    method public Double? nullableDouble(String columnName);
-    method public Long? nullableLong(String columnName);
-    method public String? nullableString(String columnName);
-    method public String string(String columnName);
-  }
-
-  public final class RowKt {
-    method @SuppressCompatibility @androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi public static androidx.benchmark.perfetto.Row rowOf(kotlin.Pair<java.lang.String,? extends java.lang.Object?>... pairs);
-  }
-
-}
-
diff --git a/benchmark/benchmark-macro/build.gradle b/benchmark/benchmark-macro/build.gradle
index 5ec5df2..e602a99 100644
--- a/benchmark/benchmark-macro/build.gradle
+++ b/benchmark/benchmark-macro/build.gradle
@@ -31,7 +31,6 @@
     id("AndroidXPlugin")
     id("com.android.library")
     id("kotlin-android")
-    id("com.squareup.wire")
 }
 
 android {
@@ -113,62 +112,12 @@
         freeCompilerArgs += [
                 "-opt-in=androidx.benchmark.ExperimentalBenchmarkConfigApi",
                 "-opt-in=androidx.benchmark.macro.ExperimentalMetricApi",
-                "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi",
                 "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi",
+                "-opt-in=androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi",
         ]
     }
 }
 
-wire {
-    kotlin {}
-    sourcePath {
-        srcDir AndroidXConfig.getPrebuiltsRoot(project).absolutePath + '/androidx/traceprocessor'
-
-        // currently, all protos are at same tree depth
-        // can add further includes if this stops working
-        include 'protos/perfetto/*/*.proto'
-    }
-
-    prune 'perfetto.protos.AndroidBatteryMetric'
-    prune 'perfetto.protos.AndroidBinderMetric'
-    prune 'perfetto.protos.AndroidCameraMetric'
-    prune 'perfetto.protos.AndroidCameraUnaggregatedMetric'
-    prune 'perfetto.protos.AndroidCpuMetric'
-    prune 'perfetto.protos.AndroidDisplayMetrics'
-    prune 'perfetto.protos.AndroidDmaHeapMetric'
-    prune 'perfetto.protos.AndroidDvfsMetric'
-    prune 'perfetto.protos.AndroidFastrpcMetric'
-    prune 'perfetto.protos.AndroidFrameTimelineMetric'
-    prune 'perfetto.protos.AndroidGpuMetric'
-    prune 'perfetto.protos.AndroidHwcomposerMetrics'
-    prune 'perfetto.protos.AndroidHwuiMetric'
-    prune 'perfetto.protos.AndroidIonMetric'
-    prune 'perfetto.protos.AndroidIrqRuntimeMetric'
-    prune 'perfetto.protos.AndroidJankCujMetric'
-    prune 'perfetto.protos.AndroidLmkMetric'
-    prune 'perfetto.protos.AndroidLmkReasonMetric'
-    prune 'perfetto.protos.AndroidMemoryMetric'
-    prune 'perfetto.protos.AndroidMemoryUnaggregatedMetric'
-    prune 'perfetto.protos.AndroidMultiuserMetric'
-    prune 'perfetto.protos.AndroidNetworkMetric'
-    prune 'perfetto.protos.AndroidPackageList'
-    prune 'perfetto.protos.AndroidPowerRails'
-    prune 'perfetto.protos.AndroidProcessMetadata'
-    prune 'perfetto.protos.AndroidRtRuntimeMetric'
-    prune 'perfetto.protos.AndroidSimpleperfMetric'
-    prune 'perfetto.protos.AndroidSurfaceflingerMetric'
-    prune 'perfetto.protos.AndroidTaskNames'
-    prune 'perfetto.protos.AndroidTraceQualityMetric'
-    prune 'perfetto.protos.G2dMetrics'
-    prune 'perfetto.protos.JavaHeapHistogram'
-    prune 'perfetto.protos.JavaHeapStats'
-    prune 'perfetto.protos.ProcessRenderInfo'
-    prune 'perfetto.protos.ProfilerSmaps'
-    prune 'perfetto.protos.TraceAnalysisStats'
-    prune 'perfetto.protos.TraceMetadata'
-    prune 'perfetto.protos.UnsymbolizedFrames'
-}
-
 // Define a task dependency so the app is installed before we run macro benchmarks.
 afterEvaluate {
     // `:benchmark:integration-tests:macrobenchmark-target:installRelease` is not in the compose
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
index 0b31597..4f63111 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
@@ -18,7 +18,7 @@
 
 import androidx.benchmark.DeviceInfo
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
@@ -91,7 +91,7 @@
                 )
             metric.configure(captureInfo)
             val result =
-                PerfettoTraceProcessor.runSingleSessionServer(tracePath) {
+                TraceProcessor.runSingleSessionServer(tracePath) {
                     metric.getMeasurements(
                         // note that most args are incorrect here, but currently
                         // only targetPackageName matters in this context
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
index 68c4be8..129d5e9 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
@@ -21,7 +21,7 @@
 import androidx.benchmark.TraceDeepLink
 import androidx.benchmark.createInsightSummaries
 import androidx.benchmark.macro.perfetto.queryStartupInsights
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.MediumTest
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
@@ -117,7 +117,7 @@
     @MediumTest
     @Test
     fun queryStartupInsights() {
-        PerfettoTraceProcessor.runSingleSessionServer(api35ColdStart) {
+        TraceProcessor.runSingleSessionServer(api35ColdStart) {
             assertThat(
                     queryStartupInsights(
                         helpUrlBase = "https://d.android.com/test#",
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PerfettoTraceRuleTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PerfettoTraceRuleTest.kt
index e67db61..c4cd85f 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PerfettoTraceRuleTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PerfettoTraceRuleTest.kt
@@ -19,8 +19,8 @@
 import androidx.benchmark.junit4.PerfettoTraceRule
 import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.tracing.trace
@@ -58,7 +58,7 @@
                         if (PerfettoHelper.isAbiSupported()) {
                             assertNotNull(trace)
                             val sliceNameInstances =
-                                PerfettoTraceProcessor.runSingleSessionServer(trace!!.path) {
+                                TraceProcessor.runSingleSessionServer(trace!!.path) {
                                     querySlices(UNIQUE_SLICE_NAME, packageName = null).map { slice
                                         ->
                                         slice.name
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PowerMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PowerMetricTest.kt
index 99415a4..44d0c75 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PowerMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/PowerMetricTest.kt
@@ -18,7 +18,7 @@
 
 import androidx.benchmark.macro.Metric.Measurement
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.SdkSuppress
 import kotlin.test.assertEquals
 import org.junit.Assume.assumeTrue
@@ -44,7 +44,7 @@
             PowerCategory.values().associateWith { PowerCategoryDisplayLevel.BREAKDOWN }
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerMetric(PowerMetric.Energy(categories)).getMeasurements(captureInfo, this)
             }
 
@@ -82,7 +82,7 @@
         val categories = PowerCategory.values().associateWith { PowerCategoryDisplayLevel.TOTAL }
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerMetric(PowerMetric.Power(categories)).getMeasurements(captureInfo, this)
             }
 
@@ -118,7 +118,7 @@
             )
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerMetric(PowerMetric.Power(categories)).getMeasurements(captureInfo, this)
             }
 
@@ -147,7 +147,7 @@
             PowerCategory.values().associateWith { PowerCategoryDisplayLevel.BREAKDOWN }
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerMetric(PowerMetric.Energy(categories)).getMeasurements(captureInfo, this)
             }
 
@@ -162,7 +162,7 @@
         val traceFile = createTempFileFromAsset("api31_battery_discharge", ".perfetto-trace")
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerMetric(PowerMetric.Battery()).getMeasurements(captureInfo, this)
             }
 
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
index b3b9c7c..cadb7a0 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/StartupTimingMetricTest.kt
@@ -27,7 +27,7 @@
 import androidx.benchmark.perfetto.PerfettoCaptureWrapper
 import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.MediumTest
@@ -212,7 +212,7 @@
             )
 
         metric.configure(captureInfo)
-        return PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+        return TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
             metric.getMeasurements(captureInfo = captureInfo, traceSession = this)
         }
     }
@@ -237,7 +237,7 @@
         metric.configure(captureInfo)
 
         val measurements =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 metric.getMeasurements(captureInfo = captureInfo, traceSession = this)
             }
 
@@ -321,7 +321,7 @@
                 block = measureBlock
             )!!
 
-    return PerfettoTraceProcessor.runSingleSessionServer(tracePath) {
+    return TraceProcessor.runSingleSessionServer(tracePath) {
         metric.getMeasurements(captureInfo = captureInfo, traceSession = this)
     }
 }
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceMetricTest.kt
index 279a95d..d31f120 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceMetricTest.kt
@@ -17,7 +17,7 @@
 package androidx.benchmark.macro
 
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.MediumTest
 import org.junit.Assume.assumeTrue
 import org.junit.Test
@@ -35,7 +35,7 @@
     class ActivityResumeMetric : TraceMetric() {
         override fun getMeasurements(
             captureInfo: CaptureInfo,
-            traceSession: PerfettoTraceProcessor.Session
+            traceSession: TraceProcessor.Session
         ): List<Measurement> {
             val rowSequence =
                 traceSession.query(
@@ -83,7 +83,7 @@
             metric.configure(captureInfo)
 
             val result =
-                PerfettoTraceProcessor.runSingleSessionServer(tracePath) {
+                TraceProcessor.runSingleSessionServer(tracePath) {
                     metric.getMeasurements(captureInfo = captureInfo, traceSession = this)
                 }
 
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
index b96ad25..27fc565 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
@@ -17,7 +17,7 @@
 package androidx.benchmark.macro
 
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.MediumTest
 import org.junit.Assume.assumeTrue
 import org.junit.Test
@@ -172,7 +172,7 @@
             metric.configure(captureInfo)
 
             val result =
-                PerfettoTraceProcessor.runSingleSessionServer(tracePath) {
+                TraceProcessor.runSingleSessionServer(tracePath) {
                     metric.getMeasurements(captureInfo = captureInfo, traceSession = this)
                 }
 
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/AndroidxTracingTraceTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/AndroidxTracingTraceTest.kt
index a265211..1bc7b49 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/AndroidxTracingTraceTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/AndroidxTracingTraceTest.kt
@@ -18,12 +18,13 @@
 
 import androidx.benchmark.macro.FileLinkingRule
 import androidx.benchmark.macro.Packages
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoCapture
 import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoHelper
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.toSlices
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.benchmark.traceprocessor.toSlices
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -101,7 +102,7 @@
         perfettoCapture.stop(traceFilePath)
 
         val queryResult =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFilePath) { query(query = QUERY) }
+            TraceProcessor.runSingleSessionServer(traceFilePath) { query(query = QUERY) }
 
         val matchingSlices = queryResult.toSlices()
         assertEquals(
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/BatteryDischargeQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/BatteryDischargeQueryTest.kt
index 33c5422..5c8fb6a 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/BatteryDischargeQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/BatteryDischargeQueryTest.kt
@@ -21,8 +21,9 @@
 import androidx.benchmark.macro.ExperimentalMetricApi
 import androidx.benchmark.macro.PowerMetric
 import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
@@ -42,7 +43,7 @@
         val traceFile = createTempFileFromAsset("api31_battery_discharge", ".perfetto-trace")
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 val slice =
                     querySlices(PowerMetric.MEASURE_BLOCK_SECTION_NAME, packageName = null).first()
                 BatteryDischargeQuery.getBatteryDischargeMetrics(this, slice)
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/FrameTimingQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/FrameTimingQueryTest.kt
index 365e841..2051074 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/FrameTimingQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/FrameTimingQueryTest.kt
@@ -22,8 +22,9 @@
 import androidx.benchmark.macro.perfetto.FrameTimingQuery.SubMetric.FrameDurationUiNs
 import androidx.benchmark.macro.perfetto.FrameTimingQuery.SubMetric.FrameOverrunNs
 import androidx.benchmark.macro.perfetto.FrameTimingQuery.getFrameSubMetrics
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import kotlin.test.assertEquals
@@ -41,7 +42,7 @@
         val traceFile = createTempFileFromAsset("api28_scroll", ".perfetto-trace")
 
         val frameSubMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 FrameTimingQuery.getFrameData(
                         session = this,
                         captureApiLevel = 28,
@@ -72,7 +73,7 @@
         val traceFile = createTempFileFromAsset("api31_scroll", ".perfetto-trace")
 
         val frameSubMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 FrameTimingQuery.getFrameData(
                         session = this,
                         captureApiLevel = 31,
@@ -109,7 +110,7 @@
         val traceFile = createTempFileFromAsset("api33_motionlayout_messagejson", ".perfetto-trace")
 
         val frameData =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 FrameTimingQuery.getFrameData(
                     session = this,
                     captureApiLevel = 33,
@@ -164,7 +165,7 @@
         val traceFile = createTempFileFromAsset("api34_invalid_expect_actual", ".perfetto-trace")
 
         val frameData =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 FrameTimingQuery.getFrameData(
                     session = this,
                     captureApiLevel = 34,
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryCountersQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryCountersQueryTest.kt
index f3efe05..2ba48bf 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryCountersQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryCountersQueryTest.kt
@@ -17,8 +17,9 @@
 package androidx.benchmark.macro.perfetto
 
 import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import kotlin.test.assertEquals
@@ -34,7 +35,7 @@
         assumeTrue(PerfettoHelper.isAbiSupported())
         val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
         val metrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 MemoryCountersQuery.getMemoryCounters(
                     this,
                     "androidx.benchmark.integration.macrobenchmark.target"
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryUsageQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryUsageQueryTest.kt
index 77a5791..a2f6739 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryUsageQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/MemoryUsageQueryTest.kt
@@ -19,8 +19,9 @@
 import androidx.benchmark.macro.MemoryUsageMetric
 import androidx.benchmark.macro.MemoryUsageMetric.SubMetric
 import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import kotlin.test.assertEquals
@@ -35,7 +36,7 @@
     fun fixedTrace31() {
         assumeTrue(PerfettoHelper.isAbiSupported())
         val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
             // Note: this particular trace has same values for last and max
             val expected =
                 mapOf(
@@ -68,7 +69,7 @@
     fun fixedTrace33() {
         assumeTrue(PerfettoHelper.isAbiSupported())
         val traceFile = createTempFileFromAsset("api33_motionlayout_messagejson", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
             assertEquals(
                 mapOf(
                     SubMetric.HeapSize to 25019,
@@ -103,7 +104,7 @@
     fun fixedGpuTrace34() {
         assumeTrue(PerfettoHelper.isAbiSupported())
         val traceFile = createTempFileFromAsset("api34_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
             assertEquals(
                 mapOf(
                     SubMetric.Gpu to 30840,
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoCaptureSweepTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoCaptureSweepTest.kt
index b7468b4..ea893e6 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoCaptureSweepTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoCaptureSweepTest.kt
@@ -20,12 +20,13 @@
 import android.os.Build
 import androidx.benchmark.macro.FileLinkingRule
 import androidx.benchmark.macro.Packages
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoCapture
 import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoHelper
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.MIN_BUNDLED_SDK_VERSION
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
 import androidx.tracing.Trace
@@ -119,7 +120,7 @@
         perfettoCapture.stop(traceFilePath)
 
         val matchingSlices =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFilePath) {
+            TraceProcessor.runSingleSessionServer(traceFilePath) {
                 querySlices("PerfettoCaptureTest_%", packageName = null)
             }
 
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoSdkTraceTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoSdkTraceTest.kt
index 6c4392e..07cf35d 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoSdkTraceTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PerfettoSdkTraceTest.kt
@@ -18,9 +18,10 @@
 
 import android.os.Build
 import androidx.benchmark.junit4.PerfettoTraceRule
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
 import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
 import androidx.tracing.trace
@@ -62,7 +63,7 @@
                     .flatMap { it }
                     .toList()
             val actualSlices =
-                PerfettoTraceProcessor.runSingleSessionServer(trace.path) {
+                TraceProcessor.runSingleSessionServer(trace.path) {
                     StringSource.allTraceStrings.flatMap {
                         querySlices(it, packageName = null).map { s -> s.name }
                     }
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PowerQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PowerQueryTest.kt
index ced85ef..2b95dc7 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PowerQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/PowerQueryTest.kt
@@ -20,8 +20,9 @@
 import androidx.benchmark.macro.PowerCategory
 import androidx.benchmark.macro.PowerMetric.Companion.MEASURE_BLOCK_SECTION_NAME
 import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
@@ -41,7 +42,7 @@
 
         val traceFile = createTempFileFromAsset("api32_odpm_rails", ".perfetto-trace")
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerQuery.getPowerMetrics(
                     this,
                     querySlices(MEASURE_BLOCK_SECTION_NAME, packageName = null).first()
@@ -198,7 +199,7 @@
         val traceFile = createTempFileFromAsset("api31_odpm_rails_empty", ".perfetto-trace")
 
         val actualMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 PowerQuery.getPowerMetrics(
                     this,
                     querySlices(MEASURE_BLOCK_SECTION_NAME, packageName = null).first()
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
index cc3d69f..901592b 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
@@ -18,8 +18,9 @@
 
 import androidx.benchmark.macro.StartupMode
 import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runSingleSessionServer
 import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import java.util.Locale
@@ -41,7 +42,7 @@
         val traceFile = createTempFileFromAsset(prefix = tracePrefix, suffix = ".perfetto-trace")
 
         val startupSubMetrics =
-            PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
                 StartupTimingQuery.getFrameSubMetrics(
                     session = this,
                     captureApiLevel = api,
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/PerfettoTraceProcessorTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/PerfettoTraceProcessorTest.kt
deleted file mode 100644
index 5fcc5ca..0000000
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/PerfettoTraceProcessorTest.kt
+++ /dev/null
@@ -1,401 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import androidx.benchmark.Outputs
-import androidx.benchmark.Shell
-import androidx.benchmark.macro.createTempFileFromAsset
-import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.LargeTest
-import androidx.test.filters.MediumTest
-import androidx.test.platform.app.InstrumentationRegistry
-import java.io.File
-import java.net.ConnectException
-import java.net.HttpURLConnection
-import java.net.URL
-import kotlin.test.assertContains
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-import kotlin.test.assertNull
-import kotlin.time.Duration.Companion.milliseconds
-import org.junit.Assert.assertTrue
-import org.junit.Assume.assumeFalse
-import org.junit.Assume.assumeTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import perfetto.protos.TraceMetrics
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class PerfettoTraceProcessorTest {
-    @Test
-    fun shellPath() {
-        assumeTrue(isAbiSupported())
-        val shellPath = PerfettoTraceProcessor.shellPath
-        val out = Shell.executeScriptCaptureStdout("$shellPath --version")
-        assertTrue("expect to get Perfetto version string, saw: $out", out.contains("Perfetto v"))
-    }
-
-    @Test
-    fun getJsonMetrics_tracePathWithSpaces() {
-        assumeTrue(isAbiSupported())
-        assertFailsWith<IllegalArgumentException> {
-            PerfettoTraceProcessor.runSingleSessionServer("/a b") {}
-        }
-    }
-
-    @Test
-    fun getJsonMetrics_metricWithSpaces() {
-        assumeTrue(isAbiSupported())
-        assertFailsWith<IllegalArgumentException> {
-            PerfettoTraceProcessor.runSingleSessionServer(
-                createTempFileFromAsset("api31_startup_cold", ".perfetto-trace").absolutePath
-            ) {
-                getTraceMetrics("a b")
-            }
-        }
-    }
-
-    @Test
-    fun validateAbiNotSupportedBehavior() {
-        assumeFalse(isAbiSupported())
-        assertFailsWith<IllegalStateException> { PerfettoTraceProcessor.shellPath }
-
-        assertFailsWith<IllegalStateException> {
-            PerfettoTraceProcessor.runSingleSessionServer(
-                createTempFileFromAsset("api31_startup_cold", ".perfetto-trace").absolutePath
-            ) {
-                getTraceMetrics("ignored_metric")
-            }
-        }
-    }
-
-    enum class QuerySlicesMode(val target: String?) {
-        ValidPackage("androidx.benchmark.integration.macrobenchmark.target"),
-        Unspecified(null),
-        InvalidPackage("not.a.real.package")
-    }
-
-    @Test fun querySlices_validPackage() = validateQuerySlices(QuerySlicesMode.ValidPackage)
-
-    @Test fun querySlices_invalidPackage() = validateQuerySlices(QuerySlicesMode.InvalidPackage)
-
-    @Test fun querySlices_unspecified() = validateQuerySlices(QuerySlicesMode.Unspecified)
-
-    private fun validateQuerySlices(mode: QuerySlicesMode) {
-        // check known slice content is queryable
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            assertEquals(
-                expected =
-                    when (mode) {
-                        QuerySlicesMode.InvalidPackage -> emptyList()
-                        else ->
-                            listOf(
-                                Slice(name = "activityStart", ts = 186975009436431, dur = 29580628)
-                            )
-                    },
-                actual = querySlices("activityStart", packageName = mode.target)
-            )
-            assertEquals(
-                expected =
-                    when (mode) {
-                        QuerySlicesMode.InvalidPackage -> emptyList()
-                        else ->
-                            listOf(
-                                Slice(name = "activityStart", ts = 186975009436431, dur = 29580628),
-                                Slice(name = "activityResume", ts = 186975039764298, dur = 6570418)
-                            )
-                    },
-                actual =
-                    querySlices("activityStart", "activityResume", packageName = mode.target)
-                        .sortedBy { it.ts }
-            )
-            assertEquals(
-                expected =
-                    when (mode) {
-                        QuerySlicesMode.ValidPackage -> 7
-                        QuerySlicesMode.Unspecified -> 127
-                        QuerySlicesMode.InvalidPackage -> 0
-                    },
-                actual = querySlices("Lock contention %", packageName = mode.target).size
-            )
-        }
-    }
-
-    @Test
-    fun query_syntaxError() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val error = assertFailsWith<IllegalStateException> { query("SYNTAX ERROR, PLEASE") }
-            assertContains(
-                charSequence = error.message!!,
-                other = "syntax error",
-                message = "expected 'syntax error', saw message : '''${error.message}'''"
-            )
-        }
-    }
-
-    @Test
-    fun query() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            // raw list of maps
-            assertEquals(
-                expected =
-                    listOf(
-                        rowOf(
-                            "name" to "activityStart",
-                            "ts" to 186975009436431L,
-                            "dur" to 29580628L
-                        )
-                    ),
-                actual =
-                    query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"")
-                        .toList(),
-            )
-
-            // list of lists
-            assertEquals(
-                expected = listOf(listOf("activityStart", 186975009436431L, 29580628L)),
-                actual =
-                    query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"")
-                        .map { listOf(it.string("name"), it.long("ts"), it.long("dur")) }
-                        .toList(),
-            )
-
-            // multiple result query
-            assertEquals(
-                expected =
-                    listOf(
-                        listOf("activityStart", 186975009436431L, 29580628L),
-                        listOf("activityResume", 186975039764298L, 6570418L)
-                    ),
-                actual =
-                    query(
-                            "SELECT name,ts,dur FROM slice WHERE" +
-                                " name LIKE \"activityStart\" OR" +
-                                " name LIKE \"activityResume\""
-                        )
-                        .map { listOf(it.string("name"), it.long("ts"), it.long("dur")) }
-                        .toList(),
-            )
-        }
-    }
-
-    /** Validate parsing of bytes is possible */
-    @Test
-    fun queryBytes() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val query = "SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\""
-            val bytes = rawQuery(query)
-            val queryResult = perfetto.protos.QueryResult.ADAPTER.decode(bytes)
-            assertNull(queryResult.error, "no error expected")
-            assertEquals(
-                expected =
-                    listOf(
-                        rowOf(
-                            "name" to "activityStart",
-                            "ts" to 186975009436431L,
-                            "dur" to 29580628L
-                        )
-                    ),
-                actual = QueryResultIterator(queryResult).asSequence().toList(),
-            )
-        }
-    }
-
-    @Test
-    fun query_includeModule() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        val startups =
-            PerfettoTraceProcessor.runServer {
-                loadTrace(PerfettoTrace(traceFile.absolutePath)) {
-                    query(
-                            """
-                    INCLUDE PERFETTO MODULE android.startup.startups;
-
-                    SELECT * FROM android_startups;
-                """
-                                .trimIndent()
-                        )
-                        .toList()
-                }
-            }
-        // minimal validation, just verifying query worked
-        assertEquals(1, startups.size)
-        assertEquals(
-            "androidx.benchmark.integration.macrobenchmark.target",
-            startups.single().string("package")
-        )
-    }
-
-    @Test
-    fun queryMetricsJson() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val metrics = queryMetricsJson(listOf("android_startup"))
-            assertTrue(metrics.contains("\"android_startup\": {"))
-            assertTrue(metrics.contains("\"startup_type\": \"cold\","))
-        }
-    }
-
-    @Test
-    fun queryMetricsProtoBinary() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val metrics =
-                TraceMetrics.ADAPTER.decode(queryMetricsProtoBinary(listOf("android_startup")))
-            val startup = metrics.android_startup!!
-            assertEquals(startup.startup.single().startup_type, "cold")
-        }
-    }
-
-    @Test
-    fun queryMetricsProtoText() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val metrics = queryMetricsProtoText(listOf("android_startup"))
-            assertTrue(metrics.contains("android_startup {"))
-            assertTrue(metrics.contains("startup_type: \"cold\""))
-        }
-    }
-
-    @Test
-    fun validatePerfettoTraceProcessorBinariesExist() {
-        val context = InstrumentationRegistry.getInstrumentation().targetContext
-        val suffixes = listOf("aarch64")
-        val entries = suffixes.map { "trace_processor_shell_$it" }.toSet()
-        val assets = context.assets.list("") ?: emptyArray()
-        assertTrue("Expected to find $entries", assets.toSet().containsAll(entries))
-    }
-
-    @Test
-    fun runServerShouldHandleStartAndStopServer() {
-        assumeTrue(isAbiSupported())
-
-        // Check server is not running
-        assertTrue(!isRunning())
-
-        PerfettoTraceProcessor.runServer {
-            // Check server is running
-            assertTrue(isRunning())
-        }
-
-        // Check server is not running
-        assertTrue(!isRunning())
-    }
-
-    @Test
-    fun runServerWithNegativeTimeoutShouldStartAndStopServer() {
-        assumeTrue(isAbiSupported())
-
-        // Check server is not running
-        assertTrue(!isRunning())
-
-        PerfettoTraceProcessor.runServer((-1).milliseconds) {
-            // Check server is running
-            assertTrue(isRunning())
-        }
-
-        // Check server is not running
-        assertTrue(!isRunning())
-    }
-
-    @Test
-    fun runServerWithZeroTimeoutShouldStartAndStopServer() {
-        assumeTrue(isAbiSupported())
-
-        // Check server is not running
-        assertTrue(!isRunning())
-
-        PerfettoTraceProcessor.runServer((0).milliseconds) {
-            // Check server is running
-            assertTrue(isRunning())
-        }
-
-        // Check server is not running
-        assertTrue(!isRunning())
-    }
-
-    @Test
-    fun testParseTracesWithProcessTracks() {
-        assumeTrue(isAbiSupported())
-        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            val slices = querySlices("launching:%", packageName = null)
-            assertEquals(
-                expected =
-                    listOf(
-                        Slice(
-                            name =
-                                "launching: androidx.benchmark.integration.macrobenchmark.target",
-                            ts = 186974946587883,
-                            dur = 137401159
-                        )
-                    ),
-                slices
-            )
-        }
-    }
-
-    @LargeTest
-    @Test
-    fun parseLongTrace() {
-        val traceFile =
-            File.createTempFile("long_trace", ".trace", Outputs.dirUsableByAppAndShell).apply {
-                var length = 0L
-                val out = outputStream()
-                while (length < 70 * 1024 * 1024) {
-                    length +=
-                        InstrumentationRegistry.getInstrumentation()
-                            .context
-                            .assets
-                            .open("api31_startup_cold.perfetto-trace")
-                            .copyTo(out)
-                }
-            }
-        PerfettoTraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
-            // This would throw an exception if there is an error in the parsing.
-            getTraceMetrics("android_startup")
-        }
-    }
-
-    /**
-     * This method will return true if the server status endpoint returns 200 (that is also the only
-     * status code being returned).
-     */
-    private fun isRunning(): Boolean =
-        try {
-            val url = URL("http://localhost:${PerfettoTraceProcessor.PORT}/")
-            with(url.openConnection() as HttpURLConnection) {
-                return@with responseCode == 200
-            }
-        } catch (e: ConnectException) {
-            false
-        }
-}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/RowTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/RowTest.kt
deleted file mode 100644
index 00895dd..0000000
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/RowTest.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SmallTest
-import kotlin.test.assertContentEquals
-import kotlin.test.assertEquals
-import kotlin.test.assertNull
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@SmallTest
-@RunWith(AndroidJUnit4::class)
-class RowTest {
-    @Test
-    fun basic() {
-        val map = mapOf<String, Any?>("name" to "Name", "ts" to 0L, "dur" to 1L)
-        val row = rowOf("name" to "Name", "ts" to 0L, "dur" to 1L)
-
-        assertEquals(map, row)
-        assertEquals(map.hashCode(), row.hashCode())
-        assertEquals(map.toString(), row.toString())
-    }
-
-    @Test
-    fun gettersSetters() {
-        val row =
-            rowOf(
-                "string" to "foo",
-                "double" to 0.0,
-                "long" to 1L,
-                "bytes" to byteArrayOf(0x00, 0x01),
-                "null" to null
-            )
-
-        assertEquals("foo", row.string("string"))
-        assertEquals("foo", row.nullableString("string"))
-        assertContentEquals(byteArrayOf(0x00, 0x01), row.bytes("bytes"))
-        assertContentEquals(byteArrayOf(0x00, 0x01), row.nullableBytes("bytes"))
-        assertEquals(0.0, row.double("double"))
-        assertEquals(0.0, row.nullableDouble("double"))
-        assertEquals(1L, row.long("long"))
-        assertEquals(1L, row.nullableLong("long"))
-
-        assertNull(row.nullableString("null"))
-        assertNull(row.nullableBytes("null"))
-        assertNull(row.nullableDouble("null"))
-        assertNull(row.nullableLong("null"))
-    }
-}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/SliceTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/SliceTest.kt
deleted file mode 100644
index f1effb9..0000000
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/perfetto/SliceTest.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SmallTest
-import kotlin.test.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@SmallTest
-@RunWith(AndroidJUnit4::class)
-class SliceTest {
-    @Test
-    fun frameId() {
-        assertEquals(1234, Slice("Choreographer#doFrame 1234", 1, 2).frameId)
-    }
-
-    @Test
-    fun frameId_extended() {
-        // some OEMs have added additional metadata to standard tracepoints
-        // we'll fix these best effort as they are reported
-        assertEquals(123, Slice("Choreographer#doFrame 123 234 extra=91929", 1, 2).frameId)
-    }
-}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/RowTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/RowTest.kt
new file mode 100644
index 0000000..f52a99f
--- /dev/null
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/RowTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class RowTest {
+    @Test
+    fun basic() {
+        val map = mapOf<String, Any?>("name" to "Name", "ts" to 0L, "dur" to 1L)
+        val row = rowOf("name" to "Name", "ts" to 0L, "dur" to 1L)
+
+        assertEquals(map, row)
+        assertEquals(map.hashCode(), row.hashCode())
+        assertEquals(map.toString(), row.toString())
+    }
+
+    @Test
+    fun gettersSetters() {
+        val row =
+            rowOf(
+                "string" to "foo",
+                "double" to 0.0,
+                "long" to 1L,
+                "bytes" to byteArrayOf(0x00, 0x01),
+                "null" to null
+            )
+
+        assertEquals("foo", row.string("string"))
+        assertEquals("foo", row.nullableString("string"))
+        assertContentEquals(byteArrayOf(0x00, 0x01), row.bytes("bytes"))
+        assertContentEquals(byteArrayOf(0x00, 0x01), row.nullableBytes("bytes"))
+        assertEquals(0.0, row.double("double"))
+        assertEquals(0.0, row.nullableDouble("double"))
+        assertEquals(1L, row.long("long"))
+        assertEquals(1L, row.nullableLong("long"))
+
+        assertNull(row.nullableString("null"))
+        assertNull(row.nullableBytes("null"))
+        assertNull(row.nullableDouble("null"))
+        assertNull(row.nullableLong("null"))
+    }
+}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/SliceTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/SliceTest.kt
new file mode 100644
index 0000000..9b87666
--- /dev/null
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/SliceTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import kotlin.test.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class SliceTest {
+    @Test
+    fun frameId() {
+        assertEquals(1234, Slice("Choreographer#doFrame 1234", 1, 2).frameId)
+    }
+
+    @Test
+    fun frameId_extended() {
+        // some OEMs have added additional metadata to standard tracepoints
+        // we'll fix these best effort as they are reported
+        assertEquals(123, Slice("Choreographer#doFrame 123 234 extra=91929", 1, 2).frameId)
+    }
+}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/TraceProcessorTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/TraceProcessorTest.kt
new file mode 100644
index 0000000..87e1566
--- /dev/null
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/traceprocessor/TraceProcessorTest.kt
@@ -0,0 +1,404 @@
+/*
+ * Copyright 2023 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 androidx.benchmark.traceprocessor
+
+import androidx.benchmark.Outputs
+import androidx.benchmark.Shell
+import androidx.benchmark.macro.ShellServerLifecycleManager
+import androidx.benchmark.macro.createTempFileFromAsset
+import androidx.benchmark.macro.runServer
+import androidx.benchmark.macro.runSingleSessionServer
+import androidx.benchmark.perfetto.PerfettoHelper.Companion.isAbiSupported
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.File
+import java.net.ConnectException
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.time.Duration.Companion.milliseconds
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeFalse
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import perfetto.protos.TraceMetrics
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class TraceProcessorTest {
+    @Test
+    fun shellPath() {
+        assumeTrue(isAbiSupported())
+        val shellPath = ShellServerLifecycleManager.shellPath
+        val out = Shell.executeScriptCaptureStdout("$shellPath --version")
+        assertTrue("expect to get Perfetto version string, saw: $out", out.contains("Perfetto v"))
+    }
+
+    @Test
+    fun getJsonMetrics_tracePathWithSpaces() {
+        assumeTrue(isAbiSupported())
+        assertFailsWith<IllegalArgumentException> {
+            TraceProcessor.runSingleSessionServer("/a b") {}
+        }
+    }
+
+    @Test
+    fun getJsonMetrics_metricWithSpaces() {
+        assumeTrue(isAbiSupported())
+        assertFailsWith<IllegalArgumentException> {
+            TraceProcessor.runSingleSessionServer(
+                createTempFileFromAsset("api31_startup_cold", ".perfetto-trace").absolutePath
+            ) {
+                getTraceMetrics("a b")
+            }
+        }
+    }
+
+    @Test
+    fun validateAbiNotSupportedBehavior() {
+        assumeFalse(isAbiSupported())
+        assertFailsWith<IllegalStateException> { ShellServerLifecycleManager.shellPath }
+
+        assertFailsWith<IllegalStateException> {
+            TraceProcessor.runSingleSessionServer(
+                createTempFileFromAsset("api31_startup_cold", ".perfetto-trace").absolutePath
+            ) {
+                getTraceMetrics("ignored_metric")
+            }
+        }
+    }
+
+    enum class QuerySlicesMode(val target: String?) {
+        ValidPackage("androidx.benchmark.integration.macrobenchmark.target"),
+        Unspecified(null),
+        InvalidPackage("not.a.real.package")
+    }
+
+    @Test fun querySlices_validPackage() = validateQuerySlices(QuerySlicesMode.ValidPackage)
+
+    @Test fun querySlices_invalidPackage() = validateQuerySlices(QuerySlicesMode.InvalidPackage)
+
+    @Test fun querySlices_unspecified() = validateQuerySlices(QuerySlicesMode.Unspecified)
+
+    private fun validateQuerySlices(mode: QuerySlicesMode) {
+        // check known slice content is queryable
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            assertEquals(
+                expected =
+                    when (mode) {
+                        QuerySlicesMode.InvalidPackage -> emptyList()
+                        else ->
+                            listOf(
+                                Slice(name = "activityStart", ts = 186975009436431, dur = 29580628)
+                            )
+                    },
+                actual = querySlices("activityStart", packageName = mode.target)
+            )
+            assertEquals(
+                expected =
+                    when (mode) {
+                        QuerySlicesMode.InvalidPackage -> emptyList()
+                        else ->
+                            listOf(
+                                Slice(name = "activityStart", ts = 186975009436431, dur = 29580628),
+                                Slice(name = "activityResume", ts = 186975039764298, dur = 6570418)
+                            )
+                    },
+                actual =
+                    querySlices("activityStart", "activityResume", packageName = mode.target)
+                        .sortedBy { it.ts }
+            )
+            assertEquals(
+                expected =
+                    when (mode) {
+                        QuerySlicesMode.ValidPackage -> 7
+                        QuerySlicesMode.Unspecified -> 127
+                        QuerySlicesMode.InvalidPackage -> 0
+                    },
+                actual = querySlices("Lock contention %", packageName = mode.target).size
+            )
+        }
+    }
+
+    @Test
+    fun query_syntaxError() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val error = assertFailsWith<IllegalStateException> { query("SYNTAX ERROR, PLEASE") }
+            assertContains(
+                charSequence = error.message!!,
+                other = "syntax error",
+                message = "expected 'syntax error', saw message : '''${error.message}'''"
+            )
+        }
+    }
+
+    @Test
+    fun query() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            // raw list of maps
+            assertEquals(
+                expected =
+                    listOf(
+                        rowOf(
+                            "name" to "activityStart",
+                            "ts" to 186975009436431L,
+                            "dur" to 29580628L
+                        )
+                    ),
+                actual =
+                    query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"")
+                        .toList(),
+            )
+
+            // list of lists
+            assertEquals(
+                expected = listOf(listOf("activityStart", 186975009436431L, 29580628L)),
+                actual =
+                    query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"")
+                        .map { listOf(it.string("name"), it.long("ts"), it.long("dur")) }
+                        .toList(),
+            )
+
+            // multiple result query
+            assertEquals(
+                expected =
+                    listOf(
+                        listOf("activityStart", 186975009436431L, 29580628L),
+                        listOf("activityResume", 186975039764298L, 6570418L)
+                    ),
+                actual =
+                    query(
+                            "SELECT name,ts,dur FROM slice WHERE" +
+                                " name LIKE \"activityStart\" OR" +
+                                " name LIKE \"activityResume\""
+                        )
+                        .map { listOf(it.string("name"), it.long("ts"), it.long("dur")) }
+                        .toList(),
+            )
+        }
+    }
+
+    /** Validate parsing of bytes is possible */
+    @Test
+    fun queryBytes() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val query = "SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\""
+            val bytes = rawQuery(query)
+            val queryResult = perfetto.protos.QueryResult.ADAPTER.decode(bytes)
+            assertNull(queryResult.error, "no error expected")
+            assertEquals(
+                expected =
+                    listOf(
+                        rowOf(
+                            "name" to "activityStart",
+                            "ts" to 186975009436431L,
+                            "dur" to 29580628L
+                        )
+                    ),
+                actual = QueryResultIterator(queryResult).asSequence().toList(),
+            )
+        }
+    }
+
+    @Test
+    fun query_includeModule() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        val startups =
+            TraceProcessor.runServer {
+                loadTrace(PerfettoTrace(traceFile.absolutePath)) {
+                    query(
+                            """
+                    INCLUDE PERFETTO MODULE android.startup.startups;
+
+                    SELECT * FROM android_startups;
+                """
+                                .trimIndent()
+                        )
+                        .toList()
+                }
+            }
+        // minimal validation, just verifying query worked
+        assertEquals(1, startups.size)
+        assertEquals(
+            "androidx.benchmark.integration.macrobenchmark.target",
+            startups.single().string("package")
+        )
+    }
+
+    @Test
+    fun queryMetricsJson() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val metrics = queryMetricsJson(listOf("android_startup"))
+            assertTrue(metrics.contains("\"android_startup\": {"))
+            assertTrue(metrics.contains("\"startup_type\": \"cold\","))
+        }
+    }
+
+    @Test
+    fun queryMetricsProtoBinary() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val metrics =
+                TraceMetrics.ADAPTER.decode(queryMetricsProtoBinary(listOf("android_startup")))
+            val startup = metrics.android_startup!!
+            assertEquals(startup.startup.single().startup_type, "cold")
+        }
+    }
+
+    @Test
+    fun queryMetricsProtoText() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val metrics = queryMetricsProtoText(listOf("android_startup"))
+            assertTrue(metrics.contains("android_startup {"))
+            assertTrue(metrics.contains("startup_type: \"cold\""))
+        }
+    }
+
+    @Test
+    fun validateTraceProcessorBinariesExist() {
+        val context = InstrumentationRegistry.getInstrumentation().targetContext
+        val suffixes = listOf("aarch64")
+        val entries = suffixes.map { "trace_processor_shell_$it" }.toSet()
+        val assets = context.assets.list("") ?: emptyArray()
+        assertTrue("Expected to find $entries", assets.toSet().containsAll(entries))
+    }
+
+    @Test
+    fun runServerShouldHandleStartAndStopServer() {
+        assumeTrue(isAbiSupported())
+
+        // Check server is not running
+        assertTrue(!isRunning())
+
+        TraceProcessor.runServer {
+            // Check server is running
+            assertTrue(isRunning())
+        }
+
+        // Check server is not running
+        assertTrue(!isRunning())
+    }
+
+    @Test
+    fun runServerWithNegativeTimeoutShouldStartAndStopServer() {
+        assumeTrue(isAbiSupported())
+
+        // Check server is not running
+        assertTrue(!isRunning())
+
+        TraceProcessor.runServer((-1).milliseconds) {
+            // Check server is running
+            assertTrue(isRunning())
+        }
+
+        // Check server is not running
+        assertTrue(!isRunning())
+    }
+
+    @Test
+    fun runServerWithZeroTimeoutShouldStartAndStopServer() {
+        assumeTrue(isAbiSupported())
+
+        // Check server is not running
+        assertTrue(!isRunning())
+
+        TraceProcessor.runServer((0).milliseconds) {
+            // Check server is running
+            assertTrue(isRunning())
+        }
+
+        // Check server is not running
+        assertTrue(!isRunning())
+    }
+
+    @Test
+    fun testParseTracesWithProcessTracks() {
+        assumeTrue(isAbiSupported())
+        val traceFile = createTempFileFromAsset("api31_startup_cold", ".perfetto-trace")
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            val slices = querySlices("launching:%", packageName = null)
+            assertEquals(
+                expected =
+                    listOf(
+                        Slice(
+                            name =
+                                "launching: androidx.benchmark.integration.macrobenchmark.target",
+                            ts = 186974946587883,
+                            dur = 137401159
+                        )
+                    ),
+                slices
+            )
+        }
+    }
+
+    @LargeTest
+    @Test
+    fun parseLongTrace() {
+        val traceFile =
+            File.createTempFile("long_trace", ".trace", Outputs.dirUsableByAppAndShell).apply {
+                var length = 0L
+                val out = outputStream()
+                while (length < 70 * 1024 * 1024) {
+                    length +=
+                        InstrumentationRegistry.getInstrumentation()
+                            .context
+                            .assets
+                            .open("api31_startup_cold.perfetto-trace")
+                            .copyTo(out)
+                }
+            }
+        TraceProcessor.runSingleSessionServer(traceFile.absolutePath) {
+            // This would throw an exception if there is an error in the parsing.
+            getTraceMetrics("android_startup")
+        }
+    }
+
+    /**
+     * This method will return true if the server status endpoint returns 200 (that is also the only
+     * status code being returned).
+     */
+    private fun isRunning(): Boolean =
+        try {
+            val url = URL("http://localhost:${ShellServerLifecycleManager.PORT}/")
+            with(url.openConnection() as HttpURLConnection) {
+                return@with responseCode == 200
+            }
+        } catch (e: ConnectException) {
+            false
+        }
+}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
index 2f1aa74..cc63a82 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
@@ -40,7 +40,7 @@
 import androidx.benchmark.macro.MacrobenchmarkScope.KillFlushMode
 import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig
 import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig.InitialProcessState
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.platform.app.InstrumentationRegistry
 import org.junit.Assume.assumeFalse
 
@@ -263,7 +263,7 @@
     val macrobenchPackageName = InstrumentationRegistry.getInstrumentation().context.packageName
     val iterationResults = mutableListOf<IterationResult>()
 
-    PerfettoTraceProcessor.runServer {
+    TraceProcessor.runServer {
         scope.withKillFlushMode(
             current = KillFlushMode.None,
             override =
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
index c146870..bda11d3 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
@@ -29,10 +29,10 @@
 import androidx.benchmark.perfetto.PerfettoCapture
 import androidx.benchmark.perfetto.PerfettoCaptureWrapper
 import androidx.benchmark.perfetto.PerfettoConfig
-import androidx.benchmark.perfetto.PerfettoTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
 import androidx.benchmark.perfetto.UiState
 import androidx.benchmark.perfetto.appendUiState
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.tracing.trace
 import java.io.File
 
@@ -65,7 +65,7 @@
 
 /** Run a Macrobenchmark Phase and collect a list of [IterationResult]. */
 @ExperimentalBenchmarkConfigApi
-internal fun PerfettoTraceProcessor.runPhase(
+internal fun TraceProcessor.runPhase(
     uniqueName: String,
     packageName: String,
     macrobenchmarkPackageName: String,
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkScope.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkScope.kt
index 30c9738..2adfe66 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkScope.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkScope.kt
@@ -268,6 +268,13 @@
             return
         }
 
+        if (!Shell.isPackageAlive(packageName)) {
+            throw IllegalStateException(
+                "Target package $packageName is not running," +
+                    " check logcat to verify the activity launched correctly"
+            )
+        }
+
         // `am start -W` doesn't reliably wait for process to complete and renderthread to produce
         // a new frame (b/226179160), so we use `dumpsys gfxinfo <package> framestats` to determine
         // when the next frame is produced.
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
index 5ec4de0..4810691 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
@@ -22,6 +22,7 @@
 import androidx.benchmark.DeviceInfo
 import androidx.benchmark.Shell
 import androidx.benchmark.macro.BatteryCharge.hasMinimumCharge
+import androidx.benchmark.macro.PowerMetric.Companion.deviceSupportsHighPrecisionTracking
 import androidx.benchmark.macro.PowerMetric.Type
 import androidx.benchmark.macro.PowerRail.hasMetrics
 import androidx.benchmark.macro.TraceSectionMetric.Mode
@@ -34,8 +35,8 @@
 import androidx.benchmark.macro.perfetto.PowerQuery
 import androidx.benchmark.macro.perfetto.StartupTimingQuery
 import androidx.benchmark.macro.perfetto.camelCase
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Slice
+import androidx.benchmark.traceprocessor.Slice
+import androidx.benchmark.traceprocessor.TraceProcessor
 import androidx.test.platform.app.InstrumentationRegistry
 
 /** Metric interface. */
@@ -49,7 +50,7 @@
     /** After stopping, collect metrics */
     internal abstract fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement>
 
     /**
@@ -202,7 +203,7 @@
 class FrameTimingMetric : Metric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         val frameData =
             FrameTimingQuery.getFrameData(
@@ -320,7 +321,7 @@
 
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         return metrics
             .map {
@@ -352,7 +353,7 @@
 class StartupTimingMetric : Metric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         return StartupTimingQuery.getFrameSubMetrics(
                 session = traceSession,
@@ -382,7 +383,7 @@
 class StartupTimingLegacyMetric : Metric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         // Acquires perfetto metrics
         val traceMetrics = traceSession.getTraceMetrics("android_startup")
@@ -415,7 +416,7 @@
 }
 
 /**
- * Metric which captures results from a Perfetto trace with custom [PerfettoTraceProcessor] queries.
+ * Metric which captures results from a Perfetto trace with custom [TraceProcessor] queries.
  *
  * This is a more customizable version of [TraceSectionMetric] which can perform arbitrary queries
  * against the captured PerfettoTrace.
@@ -426,7 +427,7 @@
  * class ActivityResumeMetric : TraceMetric() {
  *     override fun getMeasurements(
  *         captureInfo: CaptureInfo,
- *         traceSession: PerfettoTraceProcessor.Session
+ *         traceSession: TraceProcessor.Session
  *     ): List<Measurement> {
  *         val rowSequence = traceSession.query(
  *             """
@@ -455,9 +456,9 @@
  * }
  * ```
  *
- * @see PerfettoTraceProcessor
- * @see PerfettoTraceProcessor.Session
- * @see PerfettoTraceProcessor.Session.query
+ * @see TraceProcessor
+ * @see TraceProcessor.Session
+ * @see TraceProcessor.Session.query
  */
 @ExperimentalMetricApi
 abstract class TraceMetric : Metric() {
@@ -467,7 +468,7 @@
      */
     public abstract override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement>
 }
 
@@ -501,9 +502,9 @@
     /**
      * Section name or pattern to match.
      *
-     * "%" can be used as a wildcard, as this is supported by the underlying
-     * [PerfettoTraceProcessor] query. For example `"JIT %"` will match a section named `"JIT
-     * compiling int com.package.MyClass.method(int)"` present in the trace.
+     * "%" can be used as a wildcard, as this is supported by the underlying [TraceProcessor] query.
+     * For example `"JIT %"` will match a section named `"JIT compiling int
+     * com.package.MyClass.method(int)"` present in the trace.
      */
     private val sectionName: String,
     /**
@@ -577,7 +578,7 @@
 
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         val slices =
             traceSession.querySlices(
@@ -719,7 +720,7 @@
 class ArtMetric : Metric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         return traceSession
             .querySlices("JIT Compiling %", packageName = captureInfo.targetPackageName)
@@ -914,7 +915,7 @@
 
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         // collect metrics between trace point flags
         val slice =
@@ -929,7 +930,7 @@
     }
 
     private fun getBatteryDischargeMetrics(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         slice: Slice
     ): List<Measurement> {
         val metrics = BatteryDischargeQuery.getBatteryDischargeMetrics(session, slice)
@@ -938,10 +939,7 @@
         }
     }
 
-    private fun getPowerMetrics(
-        session: PerfettoTraceProcessor.Session,
-        slice: Slice
-    ): List<Measurement> {
+    private fun getPowerMetrics(session: TraceProcessor.Session, slice: Slice): List<Measurement> {
         val metrics = PowerQuery.getPowerMetrics(session, slice)
 
         val metricMap: Map<String, Double> = getSpecifiedMetrics(metrics)
@@ -1065,7 +1063,7 @@
 
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
 
         val suffix = mode.toString()
@@ -1089,7 +1087,7 @@
 class MemoryCountersMetric : TraceMetric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         val metrics =
             MemoryCountersQuery.getMemoryCounters(
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/TraceProcessorExtensions.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/TraceProcessorExtensions.kt
new file mode 100644
index 0000000..5ed5486
--- /dev/null
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/TraceProcessorExtensions.kt
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.macro
+
+import android.annotation.SuppressLint
+import android.os.Build
+import android.security.NetworkSecurityPolicy
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RestrictTo
+import androidx.benchmark.InMemoryTracing
+import androidx.benchmark.InstrumentationResults
+import androidx.benchmark.Outputs
+import androidx.benchmark.Profiler
+import androidx.benchmark.Shell
+import androidx.benchmark.ShellScript
+import androidx.benchmark.StartedShellScript
+import androidx.benchmark.inMemoryTrace
+import androidx.benchmark.perfetto.PerfettoHelper
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.ServerLifecycleManager
+import androidx.benchmark.traceprocessor.TraceProcessor
+import java.io.IOException
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+@RequiresApi(24)
+private object Api24Impl {
+    fun isCleartextTrafficPermittedForLocalhost() =
+        NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted("localhost")
+}
+
+internal class ShellServerLifecycleManager : ServerLifecycleManager {
+    companion object {
+        private const val SERVER_PROCESS_NAME = "trace_processor_shell"
+
+        internal val shellPath: String by lazy {
+            // Checks for ABI support
+            PerfettoHelper.createExecutable(SERVER_PROCESS_NAME)
+        }
+        internal const val PORT = 9001
+    }
+
+    private var shellScript: ShellScript? = null
+    private var startedShellScript: StartedShellScript? = null
+    private var processId: Int? = null
+
+    /**
+     * Returns a cached instance of the shell script to run the perfetto trace shell processor as
+     * http server. Note that the generated script doesn't specify the port and this must be passed
+     * as parameter when running the script.
+     */
+    private fun getOrCreateShellScript(): ShellScript =
+        shellScript
+            ?: synchronized(this) {
+                var instance = shellScript
+                if (instance != null) {
+                    return@synchronized instance
+                }
+                val script = "echo pid:$$ ; exec $shellPath -D --http-port \"$PORT\""
+                instance = Shell.createShellScript(script)
+                shellScript = instance
+                instance
+            }
+
+    @SuppressLint("BanThreadSleep")
+    override fun start(): Int {
+        inMemoryTrace("ShellServerLifecycleManager#start") {
+            if (
+                Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
+                    !Api24Impl.isCleartextTrafficPermittedForLocalhost()
+            ) {
+                throw IOException(
+                    """
+                Macrobenchmark requires cleartext HTTP traffic to the on-device localhost to enable
+                querying data from perfetto traces, such as timestamps that are used to calculate
+                metrics. This should be enabled by default via manifest merging when building with
+                Gradle.  Please refer to
+                https://d.android.com/training/articles/security-config#CleartextTrafficPermitted
+                and enable cleartext http requests towards localhost in your test android manifest.
+            """
+                        .trimIndent()
+                )
+            }
+
+            getOrCreateShellScript().start().apply {
+                processId =
+                    stdOutLineSequence().first { it.startsWith("pid:") }.split("pid:")[1].toInt()
+                startedShellScript = this
+                println("Started, processId $processId")
+            }
+        }
+        return PORT
+    }
+
+    override fun timeoutMessage(): String {
+
+        // In the event that the instrumentation app cannot connect to the
+        // trace_processor_shell server, trying to read the full stderr may make the
+        // process hang. Here we check if the process is still running to determine if
+        // that's the case and throw the correct exception.
+
+        val processRunning =
+            processId?.let { Shell.isProcessAlive(it, SERVER_PROCESS_NAME) } ?: false
+
+        return if (processRunning) {
+            "The instrumentation app cannot connect to the trace_processor_shell server."
+        } else {
+            "Perfetto trace_processor_shell did not start correctly." +
+                " Stderr = ${startedShellScript?.getOutputAndClose()?.stderr}"
+        }
+    }
+
+    override fun stop() {
+        inMemoryTrace("ShellServerLifecycleManager#stop") {
+            if (processId == null) {
+                Log.w(TAG, "Tried to stop trace shell processor http server without starting it.")
+                return
+            }
+            println("stop, processId=$processId")
+            Shell.executeScriptSilent("kill -TERM $processId")
+            Log.i(TAG, "Perfetto trace processor shell server stopped (pid=$processId).")
+            processId = null
+        }
+    }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+fun <T> TraceProcessor.Companion.runSingleSessionServer(
+    absoluteTracePath: String,
+    block: TraceProcessor.Session.() -> T
+) = TraceProcessor.runServer { loadTrace(PerfettoTrace(absoluteTracePath), block) }
+
+/**
+ * Starts a Perfetto Trace Processor shell server in http mode, loads a trace and executes the given
+ * block. It stops the server after the block is complete
+ *
+ * Uses a default timeout of 60 seconds.
+ *
+ * @param block Command to execute using trace processor
+ */
+fun <T> TraceProcessor.Companion.runServer(block: TraceProcessor.() -> T): T =
+    TraceProcessor.runServer(60.seconds, block)
+
+/**
+ * Starts a Perfetto Trace Processor shell server in http mode, loads a trace and executes the given
+ * block. It stops the server after the block is complete
+ *
+ * @param timeout waiting for the server to start. If less or equal to zero uses 60 seconds
+ * @param block Command to execute using trace processor
+ */
+fun <T> TraceProcessor.Companion.runServer(timeout: Duration, block: TraceProcessor.() -> T): T =
+    runServer(
+        ShellServerLifecycleManager(),
+        eventCallback =
+            object : TraceProcessor.EventCallback {
+                override fun onLoadTraceFailure(trace: PerfettoTrace, throwable: Throwable) {
+                    // TODO: consider a label argument to control logging like this in the success
+                    // case as
+                    //  well, which lets us get rid of FileLinkingRule (which doesn't work well
+                    // anyway)
+                    if (trace.path.startsWith(Outputs.outputDirectory.absolutePath)) {
+                        // only link trace with failure to Studio if it's an output file
+                        InstrumentationResults.instrumentationReport {
+                            val label =
+                                "Trace with processing error: ${throwable.message?.take(50)?.trim()}..."
+                            reportSummaryToIde(
+                                profilerResults =
+                                    listOf(
+                                        Profiler.ResultFile.ofPerfettoTrace(
+                                            label = label,
+                                            absolutePath = trace.path
+                                        )
+                                    )
+                            )
+                        }
+                    }
+                }
+            },
+        tracer =
+            object : TraceProcessor.Tracer() {
+                override fun beginTraceSection(label: String) {
+                    InMemoryTracing.beginSection(label)
+                }
+
+                override fun endTraceSection() {
+                    InMemoryTracing.endSection()
+                }
+            },
+        timeout = timeout,
+        block = block,
+    )
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/BatteryDischargeQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/BatteryDischargeQuery.kt
index e403e70..ca8059d9 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/BatteryDischargeQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/BatteryDischargeQuery.kt
@@ -16,8 +16,8 @@
 
 package androidx.benchmark.macro.perfetto
 
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Slice
+import androidx.benchmark.traceprocessor.Slice
+import androidx.benchmark.traceprocessor.TraceProcessor
 
 internal object BatteryDischargeQuery {
     private fun getFullQuery(slice: Slice) =
@@ -36,7 +36,7 @@
     data class BatteryDischargeMeasurement(var name: String, var chargeMah: Double)
 
     fun getBatteryDischargeMetrics(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         slice: Slice
     ): List<BatteryDischargeMeasurement> {
         val queryResult = session.query(query = getFullQuery(slice)).toList()
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/FrameTimingQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/FrameTimingQuery.kt
index 22fb04f..2835259 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/FrameTimingQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/FrameTimingQuery.kt
@@ -16,10 +16,10 @@
 
 package androidx.benchmark.macro.perfetto
 
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Slice
-import androidx.benchmark.perfetto.processNameLikePkg
-import androidx.benchmark.perfetto.toSlices
+import androidx.benchmark.traceprocessor.Slice
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.benchmark.traceprocessor.processNameLikePkg
+import androidx.benchmark.traceprocessor.toSlices
 
 internal object FrameTimingQuery {
     private fun getFullQuery(packageName: String) =
@@ -146,7 +146,7 @@
     }
 
     internal fun getFrameData(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         captureApiLevel: Int,
         packageName: String,
     ): List<FrameData> {
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryCountersQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryCountersQuery.kt
index 5ad49d3..5aa36717 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryCountersQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryCountersQuery.kt
@@ -16,8 +16,8 @@
 
 package androidx.benchmark.macro.perfetto
 
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.processNameLikePkg
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.benchmark.traceprocessor.processNameLikePkg
 
 internal object MemoryCountersQuery {
     // https://perfetto.dev/docs/data-sources/memory-counters
@@ -58,10 +58,7 @@
         val memoryReclaimEvents: Double
     )
 
-    fun getMemoryCounters(
-        session: PerfettoTraceProcessor.Session,
-        targetPackageName: String
-    ): SubMetrics? {
+    fun getMemoryCounters(session: TraceProcessor.Session, targetPackageName: String): SubMetrics? {
         val queryResultIterator =
             session.query(query = getQuery(targetPackageName = targetPackageName))
 
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryUsageQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryUsageQuery.kt
index 680b469..c8c61e37 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryUsageQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/MemoryUsageQuery.kt
@@ -18,8 +18,8 @@
 
 import androidx.benchmark.macro.MemoryUsageMetric
 import androidx.benchmark.macro.MemoryUsageMetric.Mode
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.processNameLikePkg
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.benchmark.traceprocessor.processNameLikePkg
 
 internal object MemoryUsageQuery {
     // https://perfetto.dev/docs/data-sources/memory-counters
@@ -44,7 +44,7 @@
                 .trimIndent()
 
     fun getMemoryUsageKb(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         targetPackageName: String,
         mode: Mode
     ): Map<MemoryUsageMetric.SubMetric, Int>? {
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/PowerQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/PowerQuery.kt
index 9769eef..2553e68 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/PowerQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/PowerQuery.kt
@@ -19,8 +19,8 @@
 import androidx.benchmark.macro.ExperimentalMetricApi
 import androidx.benchmark.macro.PowerCategory
 import androidx.benchmark.macro.PowerMetric
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Slice
+import androidx.benchmark.traceprocessor.Slice
+import androidx.benchmark.traceprocessor.TraceProcessor
 
 // We want to use android_powrails.sql, but cannot as they do not split into sections with slice
 
@@ -89,7 +89,7 @@
     }
 
     fun getPowerMetrics(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         slice: Slice
     ): Map<PowerCategory, CategoryMeasurement> {
         // gather all recorded rails
@@ -127,7 +127,7 @@
     }
 
     private fun getRailMetrics(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         slice: Slice
     ): List<ComponentMeasurement> {
         val query = getFullQuery(slice)
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
index e8c3232..b0befaa 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
@@ -19,7 +19,7 @@
 import androidx.benchmark.Insight
 import androidx.benchmark.TraceDeepLink
 import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import perfetto.protos.AndroidStartupMetric.SlowStartReason
 import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
 import perfetto.protos.TraceMetrics
@@ -59,7 +59,7 @@
             else -> " ${thresholdUnit.toString().lowercase()}"
         }
 
-    val thresholdValue = expected_value.value_!!
+    val thresholdValue = expected_value!!.value_!!
 
     val thresholdString =
         StringBuilder()
@@ -75,8 +75,8 @@
                             )
                     }
                 } else {
-                    if (expected_value.higher_expected == true) append("> ")
-                    if (expected_value.higher_expected == false) append("< ")
+                    if (expected_value!!.higher_expected == true) append("> ")
+                    if (expected_value!!.higher_expected == false) append("< ")
                     append(thresholdValue)
                     append(unitSuffix)
                 }
@@ -110,7 +110,7 @@
     )
 }
 
-internal fun PerfettoTraceProcessor.Session.queryStartupInsights(
+internal fun TraceProcessor.Session.queryStartupInsights(
     helpUrlBase: String?,
     traceOutputRelativePath: String,
     iteration: Int,
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupTimingQuery.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupTimingQuery.kt
index 850a291..5897fd5 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupTimingQuery.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupTimingQuery.kt
@@ -18,10 +18,10 @@
 
 import android.util.Log
 import androidx.benchmark.macro.StartupMode
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Slice
-import androidx.benchmark.perfetto.processNameLikePkg
-import androidx.benchmark.perfetto.toSlices
+import androidx.benchmark.traceprocessor.Slice
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.benchmark.traceprocessor.processNameLikePkg
+import androidx.benchmark.traceprocessor.toSlices
 
 internal object StartupTimingQuery {
     private fun getFullQuery(targetPackageName: String) =
@@ -113,7 +113,7 @@
     }
 
     fun getFrameSubMetrics(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         captureApiLevel: Int,
         targetPackageName: String,
         startupMode: StartupMode
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/server/PerfettoHttpServer.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/server/PerfettoHttpServer.kt
deleted file mode 100644
index 4fb285b..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/server/PerfettoHttpServer.kt
+++ /dev/null
@@ -1,318 +0,0 @@
-/*
- * Copyright 2022 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 androidx.benchmark.macro.perfetto.server
-
-import android.annotation.SuppressLint
-import android.os.Build
-import android.security.NetworkSecurityPolicy
-import android.util.Log
-import androidx.annotation.RequiresApi
-import androidx.benchmark.Shell
-import androidx.benchmark.ShellScript
-import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import java.io.FileNotFoundException
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-import java.net.ConnectException
-import java.net.HttpURLConnection
-import java.net.URL
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.DurationUnit
-import perfetto.protos.AppendTraceDataResult
-import perfetto.protos.ComputeMetricArgs
-import perfetto.protos.ComputeMetricResult
-import perfetto.protos.QueryArgs
-import perfetto.protos.StatusResult
-
-/**
- * Wrapper around perfetto trace_shell_processor that communicates via http. The implementation is
- * based on the python one of the official repo:
- * https://github.com/google/perfetto/blob/master/python/perfetto/trace_processor/http.py
- */
-internal class PerfettoHttpServer {
-
-    companion object {
-        private const val HTTP_ADDRESS = "http://localhost"
-        private const val METHOD_GET = "GET"
-        private const val METHOD_POST = "POST"
-        private const val PATH_QUERY = "/query"
-        private const val PATH_COMPUTE_METRIC = "/compute_metric"
-        private const val PATH_PARSE = "/parse"
-        private const val PATH_NOTIFY_EOF = "/notify_eof"
-        private const val PATH_STATUS = "/status"
-        private const val PATH_RESTORE_INITIAL_TABLES = "/restore_initial_tables"
-
-        private const val TAG = "PerfettoHttpServer"
-        private const val SERVER_PROCESS_NAME = "trace_processor_shell"
-        private val WAIT_INTERVAL = 5.milliseconds
-        private val READ_TIMEOUT = 30.seconds
-
-        // Note that trace processor http server has a hard limit of 64Mb for payload size.
-        // https://cs.android.com/android/platform/superproject/+/master:external/perfetto/src/base/http/http_server.cc;l=33
-        private const val PARSE_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16Mb
-
-        private var shellScript: ShellScript? = null
-
-        /**
-         * Returns a cached instance of the shell script to run the perfetto trace shell processor
-         * as http server. Note that the generated script doesn't specify the port and this must be
-         * passed as parameter when running the script.
-         */
-        fun getOrCreateShellScript(): ShellScript =
-            shellScript
-                ?: synchronized(this) {
-                    var instance = shellScript
-                    if (instance != null) {
-                        return@synchronized instance
-                    }
-                    val script =
-                        "echo pid:$$ ; exec ${PerfettoTraceProcessor.shellPath} -D" +
-                            " --http-port \"${PerfettoTraceProcessor.PORT}\" "
-                    instance = Shell.createShellScript(script)
-                    shellScript = instance
-                    instance
-                }
-
-        /** Clean up the shell script */
-        fun cleanUpShellScript() =
-            synchronized(this) {
-                shellScript?.cleanUp()
-                shellScript = null
-            }
-    }
-
-    private var processId: Int? = null
-
-    /**
-     * Blocking method that runs the perfetto trace_shell_processor in server mode.
-     *
-     * @throws IllegalStateException if the server is not running by the end of the timeout.
-     */
-    @SuppressLint("BanThreadSleep")
-    fun startServer(timeout: Duration) =
-        inMemoryTrace("PerfettoHttpServer#startServer") {
-            if (processId != null) {
-                Log.w(TAG, "Tried to start a trace shell processor that is already running.")
-                return@inMemoryTrace
-            }
-
-            if (
-                Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
-                    !Api24Impl.isCleartextTrafficPermittedForLocalhost()
-            ) {
-                throw IOException(
-                    """
-                Macrobenchmark requires cleartext HTTP traffic to the on-device localhost to enable
-                querying data from perfetto traces, such as timestamps that are used to calculate
-                metrics. This should be enabled by default via manifest merging when building with
-                Gradle.  Please refer to
-                https://d.android.com/training/articles/security-config#CleartextTrafficPermitted
-                and enable cleartext http requests towards localhost in your test android manifest.
-            """
-                        .trimIndent()
-                )
-            }
-
-            val shellScript = getOrCreateShellScript().start()
-
-            processId =
-                shellScript
-                    .stdOutLineSequence()
-                    .first { it.startsWith("pid:") }
-                    .split("pid:")[1]
-                    .toInt()
-
-            // Wait for the trace_processor_shell server to start.
-            var elapsed = 0.milliseconds
-            while (!isRunning()) {
-                Thread.sleep(WAIT_INTERVAL.toLong(DurationUnit.MILLISECONDS))
-                elapsed += WAIT_INTERVAL
-                if (elapsed >= timeout) {
-
-                    // In the event that the instrumentation app cannot connect to the
-                    // trace_processor_shell server, trying to read the full stderr may make the
-                    // process hang. Here we check if the process is still running to determine if
-                    // that's the case and throw the correct exception.
-
-                    val processRunning =
-                        processId?.let { Shell.isProcessAlive(it, SERVER_PROCESS_NAME) } ?: false
-
-                    if (processRunning) {
-                        throw IllegalStateException(
-                            """
-                        The instrumentation app cannot connect to the trace_processor_shell server.
-                        """
-                                .trimIndent()
-                        )
-                    } else {
-                        throw IllegalStateException(
-                            """
-                        Perfetto trace_processor_shell did not start correctly.
-                        Process stderr:
-                        ${shellScript.getOutputAndClose().stderr}
-                        """
-                                .trimIndent()
-                        )
-                    }
-                }
-            }
-            Log.i(TAG, "Perfetto trace processor shell server started (pid=$processId).")
-        }
-
-    /** Stops the server killing the associated process */
-    fun stopServer() =
-        inMemoryTrace("PerfettoHttpServer#stopServer") {
-            if (processId == null) {
-                Log.w(TAG, "Tried to stop trace shell processor http server without starting it.")
-                return@inMemoryTrace
-            }
-            Shell.executeScriptSilent("kill -TERM $processId")
-            Log.i(TAG, "Perfetto trace processor shell server stopped (pid=$processId).")
-        }
-
-    /** Returns true whether the server is running, false otherwise. */
-    fun isRunning(): Boolean =
-        inMemoryTrace("PerfettoHttpServer#isRunning") {
-            return@inMemoryTrace try {
-                val statusResult = status()
-                return@inMemoryTrace statusResult.api_version != null &&
-                    statusResult.api_version > 0
-            } catch (e: ConnectException) {
-                // Note that this is fired when the server port is not bound yet.
-                // This can happen before the perfetto trace processor server is fully started.
-                false
-            } catch (e: FileNotFoundException) {
-                // Note that this is fired when the endpoint queried does not exist.
-                // This can happen before the perfetto trace processor server is fully started.
-                false
-            }
-        }
-
-    /**
-     * Executes the given [sqlQuery] on a previously parsed trace with custom decoding.
-     *
-     * Note that this does not decode the query result, so it's the caller's responsibility to check
-     * for errors in the result.
-     */
-    fun <T> rawQuery(sqlQuery: String, decodeBlock: (InputStream) -> T): T =
-        httpRequest(
-            method = METHOD_POST,
-            url = PATH_QUERY,
-            encodeBlock = { QueryArgs.ADAPTER.encode(it, QueryArgs(sqlQuery)) },
-            decodeBlock = decodeBlock
-        )
-
-    /** Computes the given metrics on a previously parsed trace. */
-    fun computeMetric(
-        metrics: List<String>,
-        resultFormat: ComputeMetricArgs.ResultFormat
-    ): ComputeMetricResult =
-        httpRequest(
-            method = METHOD_POST,
-            url = PATH_COMPUTE_METRIC,
-            encodeBlock = {
-                ComputeMetricArgs.ADAPTER.encode(it, ComputeMetricArgs(metrics, resultFormat))
-            },
-            decodeBlock = { ComputeMetricResult.ADAPTER.decode(it) }
-        )
-
-    /**
-     * Parses the trace file in chunks. Note that [notifyEof] should be called at the end to let the
-     * processor know that no more chunks will be sent.
-     */
-    fun parse(inputStream: InputStream): List<AppendTraceDataResult> {
-        val responses = mutableListOf<AppendTraceDataResult>()
-        while (true) {
-            val buffer = ByteArray(PARSE_PAYLOAD_SIZE)
-            val read = inputStream.read(buffer)
-            if (read <= 0) break
-            responses.add(
-                httpRequest(
-                    method = METHOD_POST,
-                    url = PATH_PARSE,
-                    encodeBlock = { it.write(buffer, 0, read) },
-                    decodeBlock = { AppendTraceDataResult.ADAPTER.decode(it) }
-                )
-            )
-        }
-        return responses
-    }
-
-    /** Notifies that the entire trace has been uploaded and no more chunks will be sent. */
-    fun notifyEof() =
-        httpRequest(
-            method = METHOD_GET,
-            url = PATH_NOTIFY_EOF,
-            encodeBlock = null,
-            decodeBlock = {}
-        )
-
-    /** Clears the loaded trace and restore the state of the initial tables */
-    fun restoreInitialTables() =
-        httpRequest(
-            method = METHOD_GET,
-            url = PATH_RESTORE_INITIAL_TABLES,
-            encodeBlock = null,
-            decodeBlock = {}
-        )
-
-    /** Checks the status of the trace_shell_processor http server. */
-    private fun status(): StatusResult =
-        httpRequest(
-            method = METHOD_GET,
-            url = PATH_STATUS,
-            encodeBlock = null,
-            decodeBlock = { StatusResult.ADAPTER.decode(it) }
-        )
-
-    private fun <T> httpRequest(
-        method: String,
-        url: String,
-        contentType: String = "application/octet-stream",
-        encodeBlock: ((OutputStream) -> Unit)?,
-        decodeBlock: ((InputStream) -> T)
-    ): T {
-        with(
-            URL("$HTTP_ADDRESS:${PerfettoTraceProcessor.PORT}$url").openConnection()
-                as HttpURLConnection
-        ) {
-            requestMethod = method
-            readTimeout = READ_TIMEOUT.toInt(DurationUnit.MILLISECONDS)
-            setRequestProperty("Content-Type", contentType)
-            if (encodeBlock != null) {
-                doOutput = true
-                encodeBlock(outputStream)
-                outputStream.close()
-            }
-            val value = decodeBlock(inputStream)
-            if (responseCode != 200) {
-                throw IllegalStateException(responseMessage)
-            }
-            return value
-        }
-    }
-}
-
-@RequiresApi(24)
-private object Api24Impl {
-    fun isCleartextTrafficPermittedForLocalhost() =
-        NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted("localhost")
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/ExperimentalPerfettoTraceProcessorApi.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/ExperimentalPerfettoTraceProcessorApi.kt
deleted file mode 100644
index 27f9387..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/ExperimentalPerfettoTraceProcessorApi.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright 2021 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 androidx.benchmark.perfetto
-
-/**
- * Annotation indicating experimental API for querying data from Perfetto traces with Perfetto Trace
- * Processor.
- */
-@RequiresOptIn
-@Retention(AnnotationRetention.BINARY)
-@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
-annotation class ExperimentalPerfettoTraceProcessorApi
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
deleted file mode 100644
index c6d5a9d..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
+++ /dev/null
@@ -1,479 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import androidx.annotation.RestrictTo
-import androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP
-import androidx.benchmark.InstrumentationResults
-import androidx.benchmark.Outputs
-import androidx.benchmark.Profiler
-import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.macro.perfetto.server.PerfettoHttpServer
-import java.io.File
-import java.io.FileInputStream
-import java.io.InputStream
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
-import org.intellij.lang.annotations.Language
-import perfetto.protos.ComputeMetricArgs
-import perfetto.protos.ComputeMetricResult
-import perfetto.protos.QueryResult
-import perfetto.protos.TraceMetrics
-
-/**
- * Kotlin API for [Perfetto Trace Processor](https://perfetto.dev/docs/analysis/trace-processor),
- * which enables SQL querying against the data stored in a Perfetto trace.
- *
- * This includes synchronous and async trace sections, kernel-level scheduling timing, binder
- * events... If it's displayed in Android Studio system trace or
- * [ui.perfetto.dev](https://ui.perfetto.dev), it can be queried from this API.
- *
- * ```
- * // Collect the duration of all slices named "activityStart" in the trace
- * val activityStartDurationNs = PerfettoTraceProcessor.runServer {
- *     loadTrace(trace) {
- *         query("SELECT dur FROM slice WHERE name LIKE \"activityStart\"").toList {
- *             it.long("dur")
- *         }
- *     }
- * }
- * ```
- *
- * Note that traces generally hold events from multiple apps, services and processes, so it's
- * recommended to filter potentially common trace events to the process you're interested in. See
- * the following example which queries `Choreographer#doFrame` slices (labelled spans of time) only
- * for a given package name:
- * ```
- * query("""
- *     |SELECT
- *     |    slice.name,slice.ts,slice.dur
- *     |FROM slice
- *     |    INNER JOIN thread_track on slice.track_id = thread_track.id
- *     |    INNER JOIN thread USING(utid)
- *     |    INNER JOIN process USING(upid)
- *     |WHERE
- *     |    slice.name LIKE "Choreographer#doFrame%" AND
- *     |    process.name LIKE "$packageName"
- *     """.trimMargin()
- * )
- * ```
- *
- * See also Perfetto project documentation:
- * * [Trace Processor overview](https://perfetto.dev/docs/analysis/trace-processor)
- * * [Common queries](https://perfetto.dev/docs/analysis/common-queries)
- *
- * @see PerfettoTrace
- */
-@ExperimentalPerfettoTraceProcessorApi
-class PerfettoTraceProcessor {
-    companion object {
-        private val SERVER_START_TIMEOUT_MS = 60.seconds
-        internal const val PORT = 9001
-
-        /**
-         * The actual [File] path to the `trace_processor_shell`.
-         *
-         * Lazily copies the `trace_processor_shell` and enables parsing of the Perfetto trace
-         * files.
-         */
-        internal val shellPath: String by lazy {
-            // Checks for ABI support
-            PerfettoHelper.createExecutable("trace_processor_shell")
-        }
-
-        /**
-         * Starts a Perfetto trace processor shell server in http mode, loads a trace and executes
-         * the given block. It stops the server after the block is complete
-         *
-         * Uses a default timeout of 5 seconds
-         *
-         * @param block Command to execute using trace processor
-         */
-        @JvmStatic
-        fun <T> runServer(block: PerfettoTraceProcessor.() -> T): T =
-            runServer(SERVER_START_TIMEOUT_MS, block)
-
-        /**
-         * Starts a Perfetto trace processor shell server in http mode, loads a trace and executes
-         * the given block. It stops the server after the block is complete
-         *
-         * @param timeout waiting for the server to start. If less or equal to zero use 5 seconds
-         * @param block Command to execute using trace processor
-         */
-        @JvmStatic
-        fun <T> runServer(timeout: Duration, block: PerfettoTraceProcessor.() -> T): T =
-            inMemoryTrace("PerfettoTraceProcessor#runServer") {
-                var actualTimeout = timeout
-                if (actualTimeout <= Duration.ZERO) {
-                    actualTimeout = SERVER_START_TIMEOUT_MS
-                }
-
-                var perfettoTraceProcessor: PerfettoTraceProcessor? = null
-                try {
-
-                    // Initializes the server process
-                    perfettoTraceProcessor = PerfettoTraceProcessor().startServer(actualTimeout)
-
-                    // Executes the query block
-                    return@inMemoryTrace inMemoryTrace("PerfettoTraceProcessor#runServer#block") {
-                        block(perfettoTraceProcessor)
-                    }
-                } finally {
-                    perfettoTraceProcessor?.stopServer()
-                }
-            }
-
-        @RestrictTo(LIBRARY_GROUP)
-        fun <T> runSingleSessionServer(absoluteTracePath: String, block: Session.() -> T) =
-            runServer {
-                loadTrace(PerfettoTrace(absoluteTracePath)) { block(this) }
-            }
-    }
-
-    /** Loads a PerfettoTrace into the trace processor server to query data out of the trace. */
-    fun <T> loadTrace(trace: PerfettoTrace, block: Session.() -> T): T {
-        loadTraceImpl(trace.path)
-        // TODO: unload trace after block
-        try {
-            return block.invoke(Session(this))
-        } catch (t: Throwable) {
-            // TODO: move this behavior to an extension function in benchmark when
-            //  this class moves out of benchmark group
-            // TODO: consider a label argument to control logging like this in the success case as
-            //  well, which lets us get rid of FileLinkingRule (which doesn't work well anyway)
-            if (trace.path.startsWith(Outputs.outputDirectory.absolutePath)) {
-                // only link trace with failure to Studio if it's an output file
-                InstrumentationResults.instrumentationReport {
-                    val label = "Trace with processing error: ${t.message?.take(50)?.trim()}..."
-                    reportSummaryToIde(
-                        profilerResults =
-                            listOf(
-                                Profiler.ResultFile.ofPerfettoTrace(
-                                    label = label,
-                                    absolutePath = trace.path
-                                )
-                            )
-                    )
-                }
-            }
-            throw t
-        }
-    }
-
-    /**
-     * Handle to query sql data from a [PerfettoTrace].
-     *
-     * @see query
-     */
-    class Session internal constructor(private val traceProcessor: PerfettoTraceProcessor) {
-        /** Computes the given metric on the previously loaded trace. */
-        @RestrictTo(LIBRARY_GROUP) // avoids exposing Proto API
-        fun getTraceMetrics(metric: String): TraceMetrics {
-            val computeResult =
-                queryAndVerifyMetricResult(
-                    listOf(metric),
-                    ComputeMetricArgs.ResultFormat.BINARY_PROTOBUF
-                )
-            return TraceMetrics.ADAPTER.decode(computeResult.metrics!!)
-        }
-
-        /**
-         * Computes the given metrics, returning the results as a binary proto.
-         *
-         * The proto format definition for decoding this binary format can be found
-         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
-         *
-         * See
-         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
-         * for an overview on trace based metrics.
-         */
-        fun queryMetricsProtoBinary(metrics: List<String>): ByteArray {
-            val computeResult =
-                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.BINARY_PROTOBUF)
-            return computeResult.metrics!!.toByteArray()
-        }
-
-        /**
-         * Computes the given metrics, returning the results as JSON text.
-         *
-         * The proto format definition for these metrics can be found
-         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
-         *
-         * See
-         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
-         * for an overview on trace based metrics.
-         */
-        fun queryMetricsJson(metrics: List<String>): String {
-            val computeResult =
-                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.JSON)
-            check(computeResult.metrics_as_json != null)
-            return computeResult.metrics_as_json
-        }
-
-        /**
-         * Computes the given metrics, returning the result as proto text.
-         *
-         * The proto format definition for these metrics can be found
-         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
-         *
-         * See
-         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
-         * for an overview on trace based metrics.
-         */
-        fun queryMetricsProtoText(metrics: List<String>): String {
-            val computeResult =
-                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.TEXTPROTO)
-            check(computeResult.metrics_as_prototext != null)
-            return computeResult.metrics_as_prototext
-        }
-
-        private fun queryAndVerifyMetricResult(
-            metrics: List<String>,
-            format: ComputeMetricArgs.ResultFormat
-        ): ComputeMetricResult {
-            val nameString = metrics.joinToString()
-            require(metrics.none { it.contains(" ") }) {
-                "Metrics must not constain spaces, metrics: $nameString"
-            }
-
-            inMemoryTrace("PerfettoTraceProcessor#getTraceMetrics $nameString") {
-                require(traceProcessor.perfettoHttpServer.isRunning()) {
-                    "Perfetto trace_shell_process is not running."
-                }
-
-                // Compute metrics
-                val computeResult = traceProcessor.perfettoHttpServer.computeMetric(metrics, format)
-                if (computeResult.error != null) {
-                    throw IllegalStateException(computeResult.error)
-                }
-
-                return computeResult
-            }
-        }
-
-        /**
-         * Computes the given query on the currently loaded trace.
-         *
-         * Each row returned by a query is returned by the `Sequence` as a [Row]. To extract data
-         * from a `Row`, query by column name. The following example does this for name, timestamp,
-         * and duration of slices:
-         * ```
-         * // Runs the provided callback on each activityStart instance in the trace,
-         * // providing name, start timestamp (in ns) and duration (in ns)
-         * fun PerfettoTraceProcessor.Session.forEachActivityStart(callback: (String, Long, Long) -> Unit) {
-         *     query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"").forEach {
-         *         callback(it.string("name"), it.long("ts"), it.long("dur")
-         *         // or, used as a map:
-         *         //callback(it["name"] as String, it["ts"] as Long, it["dur"] as Long)
-         *     }
-         * }
-         * ```
-         *
-         * @see PerfettoTraceProcessor
-         * @see PerfettoTraceProcessor.Session
-         */
-        fun query(query: String): Sequence<Row> {
-            inMemoryTrace("PerfettoTraceProcessor#query $query".take(127)) {
-                require(traceProcessor.perfettoHttpServer.isRunning()) {
-                    "Perfetto trace_shell_process is not running."
-                }
-                val queryResult =
-                    traceProcessor.perfettoHttpServer.rawQuery(query) {
-                        // Note: check for errors as part of decode, so it's immediate
-                        // instead of lazily in QueryResultIterator
-                        QueryResult.decodeAndCheckError(query, it)
-                    }
-                return Sequence { QueryResultIterator(queryResult) }
-            }
-        }
-
-        private fun QueryResult.Companion.decodeAndCheckError(
-            query: String,
-            inputStream: InputStream
-        ) =
-            ADAPTER.decode(inputStream).also {
-                check(it.error == null) {
-                    throw IllegalStateException("Error with query: --$query--, error=${it.error}")
-                }
-            }
-
-        /**
-         * Computes the given query on the currently loaded trace, returning the resulting protobuf
-         * bytes as a [ByteArray].
-         *
-         * Use [Session.query] if you do not wish to parse the Proto result yourself.
-         *
-         * The `QueryResult` protobuf definition can be found
-         * [in the Perfetto project](https://github.com/google/perfetto/blob/master/protos/perfetto/trace_processor/trace_processor.proto),
-         * which can be used to decode the result returned here with a protobuf parsing library.
-         *
-         * Note that this method does not check for errors in the protobuf, that is the caller's
-         * responsibility.
-         *
-         * @see Session.query
-         */
-        fun rawQuery(@Language("sql") query: String): ByteArray {
-            inMemoryTrace("PerfettoTraceProcessor#query $query".take(127)) {
-                require(traceProcessor.perfettoHttpServer.isRunning()) {
-                    "Perfetto trace_shell_process is not running."
-                }
-                return traceProcessor.perfettoHttpServer.rawQuery(query) { it.readBytes() }
-            }
-        }
-
-        /**
-         * Query a trace for a list of slices - name, timestamp, and duration.
-         *
-         * Note that sliceNames may include wildcard matches, such as `foo%`
-         */
-        @RestrictTo(LIBRARY_GROUP) // Slice API not currently exposed, since it doesn't track table
-        fun querySlices(
-            vararg sliceNames: String,
-            packageName: String?,
-        ): List<Slice> {
-            require(traceProcessor.perfettoHttpServer.isRunning()) {
-                "Perfetto trace_shell_process is not running."
-            }
-
-            val whereClause =
-                sliceNames.joinToString(
-                    separator = " OR ",
-                    prefix =
-                        if (packageName == null) {
-                            "("
-                        } else {
-                            processNameLikePkg(packageName) + " AND ("
-                        },
-                    postfix = ")"
-                ) {
-                    "slice_name LIKE \"$it\""
-                }
-            val innerJoins =
-                if (packageName != null) {
-                    """
-                INNER JOIN thread_track ON slice.track_id = thread_track.id
-                INNER JOIN thread USING(utid)
-                INNER JOIN process USING(upid)
-                """
-                        .trimMargin()
-                } else {
-                    ""
-                }
-
-            val processTrackInnerJoins =
-                """
-                INNER JOIN process_track ON slice.track_id = process_track.id
-                INNER JOIN process USING(upid)
-            """
-                    .trimIndent()
-
-            return query(
-                    query =
-                        """
-                    SELECT slice.name AS slice_name,ts,dur
-                    FROM slice
-                    $innerJoins
-                    WHERE $whereClause
-                    UNION
-                    SELECT process_track.name AS slice_name,ts,dur
-                    FROM slice
-                    $processTrackInnerJoins
-                    WHERE $whereClause
-                    ORDER BY ts
-                    """
-                            .trimIndent()
-                )
-                .map { row ->
-                    // Using an explicit mapper here to account for the aliasing of `slice_name`
-                    Slice(
-                        name = row.string("slice_name"),
-                        ts = row.long("ts"),
-                        dur = row.long("dur")
-                    )
-                }
-                .filter { it.dur != -1L } // filter out non-terminating slices
-                .toList()
-        }
-    }
-
-    private val perfettoHttpServer: PerfettoHttpServer = PerfettoHttpServer()
-    private var traceLoaded = false
-
-    private fun startServer(timeout: Duration): PerfettoTraceProcessor =
-        inMemoryTrace("PerfettoTraceProcessor#startServer") {
-            println("startserver($timeout)")
-            perfettoHttpServer.startServer(timeout)
-            return@inMemoryTrace this
-        }
-
-    private fun stopServer() =
-        inMemoryTrace("PerfettoTraceProcessor#stopServer") {
-            println("stopserver")
-            perfettoHttpServer.stopServer()
-        }
-
-    /**
-     * Loads a trace in the current instance of the trace processor, clearing any previous loaded
-     * trace if existing.
-     */
-    private fun loadTraceImpl(absoluteTracePath: String) {
-        inMemoryTrace("PerfettoTraceProcessor#loadTraceImpl") {
-            require(!absoluteTracePath.contains(" ")) {
-                "Trace path must not contain spaces: $absoluteTracePath"
-            }
-
-            val traceFile = File(absoluteTracePath)
-            require(traceFile.exists() && traceFile.isFile) {
-                "Trace path must exist and not be a directory: $absoluteTracePath"
-            }
-
-            // In case a previous trace was loaded, ensures to clear
-            if (traceLoaded) {
-                clearTrace()
-            }
-
-            val parseResults = perfettoHttpServer.parse(FileInputStream(traceFile))
-            parseResults.forEach { if (it.error != null) throw IllegalStateException(it.error) }
-
-            // Notifies the server that it won't receive any more trace parts
-            perfettoHttpServer.notifyEof()
-
-            traceLoaded = true
-        }
-    }
-
-    /** Clears the current loaded trace. */
-    private fun clearTrace() =
-        inMemoryTrace("PerfettoTraceProcessor#clearTrace") {
-            perfettoHttpServer.restoreInitialTables()
-            traceLoaded = false
-        }
-}
-
-/** Helper for fuzzy matching process name to package */
-internal fun processNameLikePkg(pkg: String): String {
-    // check for truncated package names, which can sometimes occur if perfetto can't capture full
-    // names, and only has 16 bytes from sched info (which results in 15 chars due to null
-    // termination)
-    val truncated =
-        if (pkg.length > 15) {
-            " OR process.name LIKE \"${pkg.takeLast(15)}\""
-        } else {
-            ""
-        }
-    return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%"$truncated)"""
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/QueryResultIterator.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/QueryResultIterator.kt
deleted file mode 100644
index dd70839..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/QueryResultIterator.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import perfetto.protos.QueryResult
-
-/** Iterator for results from a [PerfettoTraceProcessor] query. */
-internal class QueryResultIterator constructor(queryResult: QueryResult) : Iterator<Row> {
-    private val dataLists =
-        object {
-            val stringBatches = mutableListOf<String>()
-            val varIntBatches = mutableListOf<Long>()
-            val float64Batches = mutableListOf<Double>()
-            val blobBatches = mutableListOf<ByteArray>()
-
-            var stringIndex = 0
-            var varIntIndex = 0
-            var float64Index = 0
-            var blobIndex = 0
-        }
-
-    private val cells = mutableListOf<QueryResult.CellsBatch.CellType>()
-    private val columnNames = queryResult.column_names
-    private val columnCount: Int
-    private val count: Int
-
-    private var currentIndex = 0
-
-    init {
-        // Parsing every batch
-        for (batch in queryResult.batch) {
-            val stringsBatch = batch.string_cells!!.split(0x00.toChar()).dropLast(1)
-            dataLists.stringBatches.addAll(stringsBatch)
-            dataLists.varIntBatches.addAll(batch.varint_cells)
-            dataLists.float64Batches.addAll(batch.float64_cells)
-            dataLists.blobBatches.addAll(batch.blob_cells.map { it.toByteArray() })
-            cells.addAll(batch.cells)
-        }
-
-        columnCount = columnNames.size
-        count = if (columnCount > 0) cells.size / columnCount else 0
-    }
-
-    /** Returns the number of rows in the query result. */
-    fun size(): Int {
-        return count
-    }
-
-    /** Returns true whether there are no results stored in this iterator, false otherwise. */
-    fun isEmpty(): Boolean {
-        return count == 0
-    }
-
-    /** Returns true if there are more rows not yet parsed from the query result. */
-    override fun hasNext(): Boolean {
-        return currentIndex < count
-    }
-
-    /**
-     * Returns a map containing the next row of the query results.
-     *
-     * @throws IllegalArgumentException if the query returns an invalid cell type
-     * @throws NoSuchElementException if the query has no next row.
-     */
-    override fun next(): Row {
-        // Parsing logic is copied from the python project:
-        // https://github.com/google/perfetto/blob/master/python/perfetto/trace_processor/api.py#L89
-
-        if (!hasNext()) throw NoSuchElementException()
-
-        val row = mutableMapOf<String, Any?>()
-        val baseCellIndex = currentIndex * columnCount
-
-        for ((num, columnName) in columnNames.withIndex()) {
-            val colType = cells[baseCellIndex + num]
-            val colIndex: Int
-            row[columnName] =
-                when (colType) {
-                    QueryResult.CellsBatch.CellType.CELL_STRING -> {
-                        colIndex = dataLists.stringIndex
-                        dataLists.stringIndex += 1
-                        dataLists.stringBatches[colIndex]
-                    }
-                    QueryResult.CellsBatch.CellType.CELL_VARINT -> {
-                        colIndex = dataLists.varIntIndex
-                        dataLists.varIntIndex += 1
-                        dataLists.varIntBatches[colIndex]
-                    }
-                    QueryResult.CellsBatch.CellType.CELL_FLOAT64 -> {
-                        colIndex = dataLists.float64Index
-                        dataLists.float64Index += 1
-                        dataLists.float64Batches[colIndex]
-                    }
-                    QueryResult.CellsBatch.CellType.CELL_BLOB -> {
-                        colIndex = dataLists.blobIndex
-                        dataLists.blobIndex += 1
-                        dataLists.blobBatches[colIndex]
-                    }
-                    QueryResult.CellsBatch.CellType.CELL_INVALID ->
-                        throw IllegalArgumentException("Invalid cell type")
-                    QueryResult.CellsBatch.CellType.CELL_NULL -> null
-                }
-        }
-
-        currentIndex += 1
-        return Row(row)
-    }
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Row.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Row.kt
deleted file mode 100644
index ed706f2..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Row.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-/**
- * Convenience for constructing a RowResult for given column values.
- *
- * Useful when asserting expected query results in tests.
- */
-@ExperimentalPerfettoTraceProcessorApi
-fun rowOf(vararg pairs: Pair<String, Any?>): Row {
-    return Row(pairs.toMap())
-}
-
-/**
- * A Map<String, Any?> that maps column name to value in a row result from a [QueryResultIterator].
- *
- * Provides convenience methods for converting to internal base types - `String`, `Long`, `Double`,
- * and `ByteArray`.
- *
- * ```
- * session.query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"").forEach {
- *     callback(it.string("name"), it.long("ts"), it.long("dur")
- *     // or, used as a map:
- *     //callback(it["name"] as String, it["ts"] as Long, it["dur"] as Long)
- * }
- * ```
- *
- * Nullable variants of each convenience method are also provided.
- */
-@ExperimentalPerfettoTraceProcessorApi
-class Row(private val map: Map<String, Any?>) : Map<String, Any?> by map {
-    fun string(columnName: String): String = map[columnName] as String
-
-    fun long(columnName: String): Long = map[columnName] as Long
-
-    fun double(columnName: String): Double = map[columnName] as Double
-
-    fun bytes(columnName: String): ByteArray = map[columnName] as ByteArray
-
-    fun nullableString(columnName: String): String? = map[columnName] as String?
-
-    @Suppress("AutoBoxing") // primitives are already internally boxed
-    fun nullableLong(columnName: String): Long? = map[columnName] as Long?
-
-    @Suppress("AutoBoxing") // primitives are already internally boxed
-    fun nullableDouble(columnName: String): Double? = map[columnName] as Double?
-
-    fun nullableBytes(columnName: String): ByteArray? = map[columnName] as ByteArray?
-
-    override fun hashCode(): Int = map.hashCode()
-
-    override fun toString(): String = map.toString()
-
-    override fun equals(other: Any?): Boolean = map == other
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Slice.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Slice.kt
deleted file mode 100644
index 28273dd..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/Slice.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2023 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 androidx.benchmark.perfetto
-
-import androidx.annotation.RestrictTo
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-data class Slice(val name: String, val ts: Long, val dur: Long) {
-    val endTs: Long = ts + dur
-
-    val frameId: Int?
-
-    init {
-        val firstSpaceIndex = name.indexOf(" ")
-        frameId =
-            if (firstSpaceIndex >= 0) {
-                // if see a space, check for id from end of first space to next space (or end of
-                // String)
-                val secondSpaceIndex = name.indexOf(" ", firstSpaceIndex + 1)
-                val endFrameIdIndex = if (secondSpaceIndex < 0) name.length else secondSpaceIndex
-                name.substring(firstSpaceIndex + 1, endFrameIdIndex).toIntOrNull()
-            } else {
-                null
-            }
-    }
-
-    fun contains(targetTs: Long): Boolean {
-        return targetTs >= ts && targetTs <= (ts + dur)
-    }
-}
-
-/**
- * Convenient function to immediately retrieve a list of slices. Note that this method is provided
- * for convenience.
- */
-internal fun Sequence<Row>.toSlices(): List<Slice> =
-    map { Slice(name = it.string("name"), ts = it.long("ts"), dur = it.long("dur")) }.toList()
diff --git a/benchmark/benchmark-traceprocessor/api/current.txt b/benchmark/benchmark-traceprocessor/api/current.txt
new file mode 100644
index 0000000..71edcdb
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/api/current.txt
@@ -0,0 +1,77 @@
+// Signature format: 4.0
+package androidx.benchmark.traceprocessor {
+
+  @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.CONSTRUCTOR}) public @interface ExperimentalTraceProcessorApi {
+  }
+
+  public final class PerfettoTrace {
+    ctor public PerfettoTrace(String path);
+    field public static final androidx.benchmark.traceprocessor.PerfettoTrace.Companion Companion;
+  }
+
+  public static final class PerfettoTrace.Companion {
+  }
+
+  public final class Row implements kotlin.jvm.internal.markers.KMappedMarker java.util.Map<java.lang.String,java.lang.Object?> {
+    ctor public Row(java.util.Map<java.lang.String,? extends java.lang.Object?> map);
+    method public byte[] bytes(String columnName);
+    method public double double(String columnName);
+    method public long long(String columnName);
+    method public byte[]? nullableBytes(String columnName);
+    method public Double? nullableDouble(String columnName);
+    method public Long? nullableLong(String columnName);
+    method public String? nullableString(String columnName);
+    method public String string(String columnName);
+  }
+
+  public final class RowKt {
+    method public static androidx.benchmark.traceprocessor.Row rowOf(kotlin.Pair<java.lang.String,? extends java.lang.Object?>... pairs);
+  }
+
+  @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public interface ServerLifecycleManager {
+    method public int start();
+    method public void stop();
+    method public default String timeoutMessage();
+  }
+
+  public final class TraceProcessor {
+    ctor @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public TraceProcessor(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, optional androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback);
+    method public <T> T loadTrace(androidx.benchmark.traceprocessor.PerfettoTrace trace, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor.Session,? extends T> block);
+    method @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public static <T> T runServer(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback, androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+    field public static final androidx.benchmark.traceprocessor.TraceProcessor.Companion Companion;
+  }
+
+  public static final class TraceProcessor.Companion {
+    method @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public <T> T runServer(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback, androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+  }
+
+  public static interface TraceProcessor.EventCallback {
+    method public void onLoadTraceFailure(androidx.benchmark.traceprocessor.PerfettoTrace trace, Throwable throwable);
+  }
+
+  public static final class TraceProcessor.EventCallback.Noop implements androidx.benchmark.traceprocessor.TraceProcessor.EventCallback {
+    method public void onLoadTraceFailure(androidx.benchmark.traceprocessor.PerfettoTrace trace, Throwable throwable);
+    field public static final androidx.benchmark.traceprocessor.TraceProcessor.EventCallback.Noop INSTANCE;
+  }
+
+  public static final class TraceProcessor.Session {
+    method public kotlin.sequences.Sequence<androidx.benchmark.traceprocessor.Row> query(String query);
+    method public String queryMetricsJson(java.util.List<java.lang.String> metrics);
+    method public byte[] queryMetricsProtoBinary(java.util.List<java.lang.String> metrics);
+    method public String queryMetricsProtoText(java.util.List<java.lang.String> metrics);
+    method public byte[] rawQuery(String query);
+  }
+
+  public static class TraceProcessor.Tracer {
+    ctor public TraceProcessor.Tracer();
+    method public void beginTraceSection(String label);
+    method public void endTraceSection();
+    method public final inline <T> T trace(String label, kotlin.jvm.functions.Function0<? extends T> block);
+  }
+
+  public final class TraceProcessorKt {
+    method public static String processNameLikePkg(String pkg);
+  }
+
+}
+
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/benchmark/benchmark-traceprocessor/api/res-current.txt
similarity index 100%
rename from camera/camera-effects-still-portrait/api/res-current.txt
rename to benchmark/benchmark-traceprocessor/api/res-current.txt
diff --git a/benchmark/benchmark-traceprocessor/api/restricted_current.txt b/benchmark/benchmark-traceprocessor/api/restricted_current.txt
new file mode 100644
index 0000000..71edcdb
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/api/restricted_current.txt
@@ -0,0 +1,77 @@
+// Signature format: 4.0
+package androidx.benchmark.traceprocessor {
+
+  @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.CONSTRUCTOR}) public @interface ExperimentalTraceProcessorApi {
+  }
+
+  public final class PerfettoTrace {
+    ctor public PerfettoTrace(String path);
+    field public static final androidx.benchmark.traceprocessor.PerfettoTrace.Companion Companion;
+  }
+
+  public static final class PerfettoTrace.Companion {
+  }
+
+  public final class Row implements kotlin.jvm.internal.markers.KMappedMarker java.util.Map<java.lang.String,java.lang.Object?> {
+    ctor public Row(java.util.Map<java.lang.String,? extends java.lang.Object?> map);
+    method public byte[] bytes(String columnName);
+    method public double double(String columnName);
+    method public long long(String columnName);
+    method public byte[]? nullableBytes(String columnName);
+    method public Double? nullableDouble(String columnName);
+    method public Long? nullableLong(String columnName);
+    method public String? nullableString(String columnName);
+    method public String string(String columnName);
+  }
+
+  public final class RowKt {
+    method public static androidx.benchmark.traceprocessor.Row rowOf(kotlin.Pair<java.lang.String,? extends java.lang.Object?>... pairs);
+  }
+
+  @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public interface ServerLifecycleManager {
+    method public int start();
+    method public void stop();
+    method public default String timeoutMessage();
+  }
+
+  public final class TraceProcessor {
+    ctor @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public TraceProcessor(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, optional androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback);
+    method public <T> T loadTrace(androidx.benchmark.traceprocessor.PerfettoTrace trace, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor.Session,? extends T> block);
+    method @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public static <T> T runServer(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback, androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+    field public static final androidx.benchmark.traceprocessor.TraceProcessor.Companion Companion;
+  }
+
+  public static final class TraceProcessor.Companion {
+    method @SuppressCompatibility @androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi public <T> T runServer(androidx.benchmark.traceprocessor.ServerLifecycleManager serverLifecycleManager, androidx.benchmark.traceprocessor.TraceProcessor.EventCallback eventCallback, androidx.benchmark.traceprocessor.TraceProcessor.Tracer tracer, optional long timeout, kotlin.jvm.functions.Function1<? super androidx.benchmark.traceprocessor.TraceProcessor,? extends T> block);
+  }
+
+  public static interface TraceProcessor.EventCallback {
+    method public void onLoadTraceFailure(androidx.benchmark.traceprocessor.PerfettoTrace trace, Throwable throwable);
+  }
+
+  public static final class TraceProcessor.EventCallback.Noop implements androidx.benchmark.traceprocessor.TraceProcessor.EventCallback {
+    method public void onLoadTraceFailure(androidx.benchmark.traceprocessor.PerfettoTrace trace, Throwable throwable);
+    field public static final androidx.benchmark.traceprocessor.TraceProcessor.EventCallback.Noop INSTANCE;
+  }
+
+  public static final class TraceProcessor.Session {
+    method public kotlin.sequences.Sequence<androidx.benchmark.traceprocessor.Row> query(String query);
+    method public String queryMetricsJson(java.util.List<java.lang.String> metrics);
+    method public byte[] queryMetricsProtoBinary(java.util.List<java.lang.String> metrics);
+    method public String queryMetricsProtoText(java.util.List<java.lang.String> metrics);
+    method public byte[] rawQuery(String query);
+  }
+
+  public static class TraceProcessor.Tracer {
+    ctor public TraceProcessor.Tracer();
+    method public void beginTraceSection(String label);
+    method public void endTraceSection();
+    method public final inline <T> T trace(String label, kotlin.jvm.functions.Function0<? extends T> block);
+  }
+
+  public final class TraceProcessorKt {
+    method public static String processNameLikePkg(String pkg);
+  }
+
+}
+
diff --git a/benchmark/benchmark-traceprocessor/build.gradle b/benchmark/benchmark-traceprocessor/build.gradle
new file mode 100644
index 0000000..70c9024
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/build.gradle
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2024 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.
+ */
+
+import androidx.build.AndroidXConfig
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+import androidx.build.PlatformIdentifier
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+plugins {
+    id("AndroidXPlugin")
+    id("com.squareup.wire")
+}
+
+androidXMultiplatform {
+    jvm()
+    androidLibrary {
+        namespace = "androidx.benchmark.traceprocessor"
+        withAndroidTestOnDeviceBuilder {
+            it.compilationName = "instrumentedTest"
+            it.defaultSourceSetName = "androidInstrumentedTest"
+            it.sourceSetTreeName = "test"
+        }
+    }
+
+    defaultPlatform(PlatformIdentifier.JVM)
+
+    sourceSets {
+        commonMain {
+            dependencies {
+                api(libs.kotlinStdlib)
+                api("androidx.annotation:annotation:1.8.1")
+                implementation(libs.wireRuntime)
+            }
+        }
+        androidMain {
+            dependsOn(commonMain)
+        }
+    }
+}
+
+tasks.withType(KotlinCompile).configureEach {
+    kotlinOptions {
+        // Enable using experimental APIs from within same version group
+        freeCompilerArgs += [
+                "-opt-in=androidx.benchmark.traceprocessor.ExperimentalTraceProcessorApi",
+        ]
+    }
+}
+
+// Workarounds for Wire's plugin not setting code generation directory as task output correctly
+// See https://github.com/square/wire/issues/3199
+tasks.named("multiplatformSourceJar").configure {
+    dependsOn(tasks.named("generateCommonMainProtos"))
+}
+afterEvaluate {
+    tasks.named("generateJavaKzip").configure {
+        dependsOn(tasks.named("generateCommonMainProtos"))
+    }
+}
+
+wire {
+    kotlin {}
+    sourcePath {
+        srcDir AndroidXConfig.getPrebuiltsRoot(project).absolutePath + '/androidx/traceprocessor'
+
+        // currently, all protos are at same tree depth
+        // can add further includes if this stops working
+        include 'protos/perfetto/*/*.proto'
+    }
+
+    prune 'perfetto.protos.AndroidBatteryMetric'
+    prune 'perfetto.protos.AndroidBinderMetric'
+    prune 'perfetto.protos.AndroidCameraMetric'
+    prune 'perfetto.protos.AndroidCameraUnaggregatedMetric'
+    prune 'perfetto.protos.AndroidCpuMetric'
+    prune 'perfetto.protos.AndroidDisplayMetrics'
+    prune 'perfetto.protos.AndroidDmaHeapMetric'
+    prune 'perfetto.protos.AndroidDvfsMetric'
+    prune 'perfetto.protos.AndroidFastrpcMetric'
+    prune 'perfetto.protos.AndroidFrameTimelineMetric'
+    prune 'perfetto.protos.AndroidGpuMetric'
+    prune 'perfetto.protos.AndroidHwcomposerMetrics'
+    prune 'perfetto.protos.AndroidHwuiMetric'
+    prune 'perfetto.protos.AndroidIonMetric'
+    prune 'perfetto.protos.AndroidIrqRuntimeMetric'
+    prune 'perfetto.protos.AndroidJankCujMetric'
+    prune 'perfetto.protos.AndroidLmkMetric'
+    prune 'perfetto.protos.AndroidLmkReasonMetric'
+    prune 'perfetto.protos.AndroidMemoryMetric'
+    prune 'perfetto.protos.AndroidMemoryUnaggregatedMetric'
+    prune 'perfetto.protos.AndroidMultiuserMetric'
+    prune 'perfetto.protos.AndroidNetworkMetric'
+    prune 'perfetto.protos.AndroidPackageList'
+    prune 'perfetto.protos.AndroidPowerRails'
+    prune 'perfetto.protos.AndroidProcessMetadata'
+    prune 'perfetto.protos.AndroidRtRuntimeMetric'
+    prune 'perfetto.protos.AndroidSimpleperfMetric'
+    prune 'perfetto.protos.AndroidSurfaceflingerMetric'
+    prune 'perfetto.protos.AndroidTaskNames'
+    prune 'perfetto.protos.AndroidTraceQualityMetric'
+    prune 'perfetto.protos.G2dMetrics'
+    prune 'perfetto.protos.JavaHeapHistogram'
+    prune 'perfetto.protos.JavaHeapStats'
+    prune 'perfetto.protos.ProcessRenderInfo'
+    prune 'perfetto.protos.ProfilerSmaps'
+    prune 'perfetto.protos.TraceAnalysisStats'
+    prune 'perfetto.protos.TraceMetadata'
+    prune 'perfetto.protos.UnsymbolizedFrames'
+}
+
+androidx {
+    name = "Benchmark TraceProcessor"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "AndroidX Benchmark TraceProcessor"
+    metalavaK2UastEnabled = false
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+}
\ No newline at end of file
diff --git a/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/androidx/benchmark/traceprocessor/Log.android.kt b/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/androidx/benchmark/traceprocessor/Log.android.kt
new file mode 100644
index 0000000..ff8c711
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/androidx/benchmark/traceprocessor/Log.android.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import android.util.Log
+
+internal actual fun log(string: String) {
+    Log.d("Benchmark", string)
+}
diff --git a/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/perfetto/protos/package-info.java b/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/perfetto/protos/package-info.java
new file mode 100644
index 0000000..02661a3
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/androidMain/kotlin/perfetto/protos/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * Hide the perfetto.protos package, as it's an implementation detail of benchmark
+ *
+ * Note: To work around b/382741383, this file is present in both jvmMain/ and androidMain/
+ *
+ * Note: other attempts to use these protos in a benchmark process may clash with our
+ * definitions. If this becomes an issue, we can move ours to a separate, internal package.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+package perfetto.protos;
+
+import androidx.annotation.RestrictTo;
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ExperimentalTraceProcessorApi.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ExperimentalTraceProcessorApi.kt
new file mode 100644
index 0000000..abc60e3
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ExperimentalTraceProcessorApi.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+/** Annotation indicating experimental API for initializing Trace Processor. */
+@RequiresOptIn
+@Retention(AnnotationRetention.BINARY)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR)
+public annotation class ExperimentalTraceProcessorApi
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Log.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Log.kt
new file mode 100644
index 0000000..ec70912
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Log.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+internal expect fun log(string: String)
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/PerfettoTrace.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/PerfettoTrace.kt
new file mode 100644
index 0000000..fbd814f
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/PerfettoTrace.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP
+
+public class PerfettoTrace(
+    /**
+     * Absolute file path of the trace.
+     *
+     * Note that the trace is not guaranteed to be placed into an app-accessible directory, and may
+     * require shell commands to access.
+     */
+    @get:RestrictTo(LIBRARY_GROUP) public val path: String
+) {
+    // this companion object exists to enable PerfettoTrace.Companion.record to be declared
+    public companion object
+}
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/QueryResultIterator.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/QueryResultIterator.kt
new file mode 100644
index 0000000..6ea6a13
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/QueryResultIterator.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.annotation.RestrictTo
+import perfetto.protos.QueryResult
+
+/** Iterator for results from a [TraceProcessor] query. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class QueryResultIterator constructor(queryResult: QueryResult) : Iterator<Row> {
+    private val dataLists =
+        object {
+            val stringBatches = mutableListOf<String>()
+            val varIntBatches = mutableListOf<Long>()
+            val float64Batches = mutableListOf<Double>()
+            val blobBatches = mutableListOf<ByteArray>()
+
+            var stringIndex = 0
+            var varIntIndex = 0
+            var float64Index = 0
+            var blobIndex = 0
+        }
+
+    private val cells = mutableListOf<QueryResult.CellsBatch.CellType>()
+    private val columnNames = queryResult.column_names
+    private val columnCount: Int
+    private val count: Int
+
+    private var currentIndex = 0
+
+    init {
+        // Parsing every batch
+        for (batch in queryResult.batch) {
+            val stringsBatch = batch.string_cells!!.split(0x00.toChar()).dropLast(1)
+            dataLists.stringBatches.addAll(stringsBatch)
+            dataLists.varIntBatches.addAll(batch.varint_cells)
+            dataLists.float64Batches.addAll(batch.float64_cells)
+            dataLists.blobBatches.addAll(batch.blob_cells.map { it.toByteArray() })
+            cells.addAll(batch.cells)
+        }
+
+        columnCount = columnNames.size
+        count = if (columnCount > 0) cells.size / columnCount else 0
+    }
+
+    /** Returns the number of rows in the query result. */
+    public fun size(): Int {
+        return count
+    }
+
+    /** Returns true whether there are no results stored in this iterator, false otherwise. */
+    public fun isEmpty(): Boolean {
+        return count == 0
+    }
+
+    /** Returns true if there are more rows not yet parsed from the query result. */
+    override fun hasNext(): Boolean {
+        return currentIndex < count
+    }
+
+    /**
+     * Returns a map containing the next row of the query results.
+     *
+     * @throws IllegalArgumentException if the query returns an invalid cell type
+     * @throws NoSuchElementException if the query has no next row.
+     */
+    override fun next(): Row {
+        // Parsing logic is copied from the python project:
+        // https://github.com/google/perfetto/blob/master/python/perfetto/trace_processor/api.py#L89
+
+        if (!hasNext()) throw NoSuchElementException()
+
+        val row = mutableMapOf<String, Any?>()
+        val baseCellIndex = currentIndex * columnCount
+
+        for ((num, columnName) in columnNames.withIndex()) {
+            val colType = cells[baseCellIndex + num]
+            val colIndex: Int
+            row[columnName] =
+                when (colType) {
+                    QueryResult.CellsBatch.CellType.CELL_STRING -> {
+                        colIndex = dataLists.stringIndex
+                        dataLists.stringIndex += 1
+                        dataLists.stringBatches[colIndex]
+                    }
+                    QueryResult.CellsBatch.CellType.CELL_VARINT -> {
+                        colIndex = dataLists.varIntIndex
+                        dataLists.varIntIndex += 1
+                        dataLists.varIntBatches[colIndex]
+                    }
+                    QueryResult.CellsBatch.CellType.CELL_FLOAT64 -> {
+                        colIndex = dataLists.float64Index
+                        dataLists.float64Index += 1
+                        dataLists.float64Batches[colIndex]
+                    }
+                    QueryResult.CellsBatch.CellType.CELL_BLOB -> {
+                        colIndex = dataLists.blobIndex
+                        dataLists.blobIndex += 1
+                        dataLists.blobBatches[colIndex]
+                    }
+                    QueryResult.CellsBatch.CellType.CELL_INVALID ->
+                        throw IllegalArgumentException("Invalid cell type")
+                    QueryResult.CellsBatch.CellType.CELL_NULL -> null
+                }
+        }
+
+        currentIndex += 1
+        return Row(row)
+    }
+}
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Row.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Row.kt
new file mode 100644
index 0000000..79895cd
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Row.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+/**
+ * Convenience for constructing a RowResult for given column values.
+ *
+ * Useful when asserting expected query results in tests.
+ */
+public fun rowOf(vararg pairs: Pair<String, Any?>): Row {
+    return Row(pairs.toMap())
+}
+
+/**
+ * A Map<String, Any?> that maps column name to value in a row result from a [QueryResultIterator].
+ *
+ * Provides convenience methods for converting to internal base types - `String`, `Long`, `Double`,
+ * and `ByteArray`.
+ *
+ * ```
+ * session.query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"").forEach {
+ *     callback(it.string("name"), it.long("ts"), it.long("dur")
+ *     // or, used as a map:
+ *     //callback(it["name"] as String, it["ts"] as Long, it["dur"] as Long)
+ * }
+ * ```
+ *
+ * Nullable variants of each convenience method are also provided.
+ */
+public class Row(private val map: Map<String, Any?>) : Map<String, Any?> by map {
+    public fun string(columnName: String): String = map[columnName] as String
+
+    public fun long(columnName: String): Long = map[columnName] as Long
+
+    public fun double(columnName: String): Double = map[columnName] as Double
+
+    public fun bytes(columnName: String): ByteArray = map[columnName] as ByteArray
+
+    public fun nullableString(columnName: String): String? = map[columnName] as String?
+
+    @Suppress("AutoBoxing") // primitives are already internally boxed
+    public fun nullableLong(columnName: String): Long? = map[columnName] as Long?
+
+    @Suppress("AutoBoxing") // primitives are already internally boxed
+    public fun nullableDouble(columnName: String): Double? = map[columnName] as Double?
+
+    public fun nullableBytes(columnName: String): ByteArray? = map[columnName] as ByteArray?
+
+    override fun hashCode(): Int = map.hashCode()
+
+    override fun toString(): String = map.toString()
+
+    override fun equals(other: Any?): Boolean = map == other
+}
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ServerLifecycleManager.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ServerLifecycleManager.kt
new file mode 100644
index 0000000..db2a9ac
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/ServerLifecycleManager.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+@ExperimentalTraceProcessorApi
+@Suppress("NotCloseable")
+public interface ServerLifecycleManager {
+    /**
+     * Called to Start an instance of Trace Processor
+     *
+     * @return the port to use to communicate to Trace Processor
+     */
+    public fun start(): Int
+
+    /**
+     * Called to construct a more detailed failure message when [TraceProcessor] cannot be connected
+     * to.
+     */
+    public fun timeoutMessage(): String = "Unable to start perfetto process"
+
+    /** Called to stop the running instance of Trace Processor. */
+    public fun stop()
+}
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Slice.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Slice.kt
new file mode 100644
index 0000000..37a68bf
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/Slice.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.annotation.RestrictTo
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public data class Slice(val name: String, val ts: Long, val dur: Long) {
+    val endTs: Long = ts + dur
+
+    val frameId: Int?
+
+    init {
+        val firstSpaceIndex = name.indexOf(" ")
+        frameId =
+            if (firstSpaceIndex >= 0) {
+                // if see a space, check for id from end of first space to next space (or end of
+                // String)
+                val secondSpaceIndex = name.indexOf(" ", firstSpaceIndex + 1)
+                val endFrameIdIndex = if (secondSpaceIndex < 0) name.length else secondSpaceIndex
+                name.substring(firstSpaceIndex + 1, endFrameIdIndex).toIntOrNull()
+            } else {
+                null
+            }
+    }
+
+    public fun contains(targetTs: Long): Boolean {
+        return targetTs >= ts && targetTs <= (ts + dur)
+    }
+}
+
+/**
+ * Convenient function to immediately retrieve a list of slices. Note that this method is provided
+ * for convenience.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public fun Sequence<Row>.toSlices(): List<Slice> =
+    map { Slice(name = it.string("name"), ts = it.long("ts"), dur = it.long("dur")) }.toList()
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessor.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessor.kt
new file mode 100644
index 0000000..ac40112
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessor.kt
@@ -0,0 +1,476 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP
+import java.io.File
+import java.io.FileInputStream
+import java.io.InputStream
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import perfetto.protos.ComputeMetricArgs
+import perfetto.protos.ComputeMetricResult
+import perfetto.protos.QueryResult
+import perfetto.protos.TraceMetrics
+
+/**
+ * Kotlin API for [Perfetto Trace Processor](https://perfetto.dev/docs/analysis/trace-processor),
+ * which enables SQL querying against the data stored in a Perfetto trace.
+ *
+ * This includes synchronous and async trace sections, kernel-level scheduling timing, binder
+ * events... If it's displayed in Android Studio system trace or
+ * [ui.perfetto.dev](https://ui.perfetto.dev), it can be queried from this API.
+ *
+ * ```
+ * // Collect the duration of all slices named "activityStart" in the trace
+ * val activityStartDurationNs = TraceProcessor.runServer {
+ *     loadTrace(trace) {
+ *         query("SELECT dur FROM slice WHERE name LIKE \"activityStart\"").toList {
+ *             it.long("dur")
+ *         }
+ *     }
+ * }
+ * ```
+ *
+ * Note that traces generally hold events from multiple apps, services and processes, so it's
+ * recommended to filter potentially common trace events to the process you're interested in. See
+ * the following example which queries `Choreographer#doFrame` slices (labelled spans of time) only
+ * for a given package name:
+ * ```
+ * query("""
+ *     |SELECT
+ *     |    slice.name,slice.ts,slice.dur
+ *     |FROM slice
+ *     |    INNER JOIN thread_track on slice.track_id = thread_track.id
+ *     |    INNER JOIN thread USING(utid)
+ *     |    INNER JOIN process USING(upid)
+ *     |WHERE
+ *     |    slice.name LIKE "Choreographer#doFrame%" AND
+ *     |    process.name LIKE "$packageName"
+ *     """.trimMargin()
+ * )
+ * ```
+ *
+ * See also Perfetto project documentation:
+ * * [Trace Processor overview](https://perfetto.dev/docs/analysis/trace-processor)
+ * * [Common queries](https://perfetto.dev/docs/analysis/common-queries)
+ *
+ * @see PerfettoTrace
+ */
+public class TraceProcessor
+@Suppress("ExecutorRegistration") // purely synchronous
+@ExperimentalTraceProcessorApi
+constructor(
+    private val serverLifecycleManager: ServerLifecycleManager,
+    private val tracer: Tracer = Tracer(),
+    private val eventCallback: EventCallback = EventCallback.Noop,
+) {
+    public open class Tracer {
+        public open fun beginTraceSection(label: String) {}
+
+        public open fun endTraceSection() {}
+
+        public inline fun <T> trace(label: String, block: () -> T): T {
+            beginTraceSection(label)
+            return try {
+                block()
+            } finally {
+                endTraceSection()
+            }
+        }
+    }
+
+    public interface EventCallback {
+        public fun onLoadTraceFailure(trace: PerfettoTrace, throwable: Throwable)
+
+        public object Noop : EventCallback {
+            override fun onLoadTraceFailure(trace: PerfettoTrace, throwable: Throwable) {}
+        }
+    }
+
+    public companion object {
+        private val SERVER_START_TIMEOUT_MS = 60.seconds
+
+        /**
+         * Starts a Perfetto trace processor shell server in http mode, loads a trace and executes
+         * the given block.
+         *
+         * @param serverLifecycleManager controls starting and stopping the TraceProcessor process.
+         * @param eventCallback callback for events such as trace load failure.
+         * @param tracer used to trace begin and end of significant events within this managed run.
+         * @param timeout waiting for the server to start. If less or equal to zero use 60 seconds
+         * @param block Command to execute using trace processor
+         */
+        @Suppress(
+            "ExecutorRegistration", // purely synchronous
+            "MissingJvmstatic", // JvmOverload doesn't handle mangling
+        )
+        @ExperimentalTraceProcessorApi
+        @JvmStatic
+        public fun <T> runServer(
+            serverLifecycleManager: ServerLifecycleManager,
+            eventCallback: EventCallback,
+            @Suppress("ListenerLast") tracer: Tracer,
+            @Suppress("ListenerLast") timeout: Duration = SERVER_START_TIMEOUT_MS,
+            @Suppress("ListenerLast") block: TraceProcessor.() -> T
+        ): T =
+            tracer.trace("TraceProcessor#runServer") {
+                var actualTimeout = timeout
+                if (actualTimeout <= Duration.ZERO) {
+                    actualTimeout = SERVER_START_TIMEOUT_MS
+                }
+
+                var traceProcessor: TraceProcessor? = null
+                try {
+
+                    // Initializes the server process
+                    traceProcessor =
+                        TraceProcessor(
+                                eventCallback = eventCallback,
+                                serverLifecycleManager = serverLifecycleManager
+                            )
+                            .startServer(actualTimeout)
+
+                    // Executes the query block
+                    return@trace tracer.trace("TraceProcessor#runServer#block") {
+                        block(traceProcessor)
+                    }
+                } finally {
+                    traceProcessor?.stopServer()
+                }
+            }
+    }
+
+    /** Loads a PerfettoTrace into the trace processor server to query data out of the trace. */
+    public fun <T> loadTrace(trace: PerfettoTrace, block: Session.() -> T): T {
+        loadTraceImpl(trace.path)
+        // TODO: unload trace after block
+        try {
+            return block.invoke(Session(this))
+        } catch (t: Throwable) {
+            eventCallback.onLoadTraceFailure(trace, t)
+            throw t
+        }
+    }
+
+    /**
+     * Handle to query sql data from a [PerfettoTrace].
+     *
+     * @see query
+     */
+    public class Session internal constructor(private val traceProcessor: TraceProcessor) {
+        /** Computes the given metric on the previously loaded trace. */
+        @RestrictTo(LIBRARY_GROUP) // avoids exposing Proto API
+        public fun getTraceMetrics(metric: String): TraceMetrics {
+            val computeResult =
+                queryAndVerifyMetricResult(
+                    listOf(metric),
+                    ComputeMetricArgs.ResultFormat.BINARY_PROTOBUF
+                )
+            return TraceMetrics.ADAPTER.decode(computeResult.metrics!!)
+        }
+
+        /**
+         * Computes the given metrics, returning the results as a binary proto.
+         *
+         * The proto format definition for decoding this binary format can be found
+         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
+         *
+         * See
+         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
+         * for an overview on trace based metrics.
+         */
+        public fun queryMetricsProtoBinary(metrics: List<String>): ByteArray {
+            val computeResult =
+                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.BINARY_PROTOBUF)
+            return computeResult.metrics!!.toByteArray()
+        }
+
+        /**
+         * Computes the given metrics, returning the results as JSON text.
+         *
+         * The proto format definition for these metrics can be found
+         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
+         *
+         * See
+         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
+         * for an overview on trace based metrics.
+         */
+        public fun queryMetricsJson(metrics: List<String>): String {
+            val computeResult =
+                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.JSON)
+            check(computeResult.metrics_as_json != null)
+            return computeResult.metrics_as_json
+        }
+
+        /**
+         * Computes the given metrics, returning the result as proto text.
+         *
+         * The proto format definition for these metrics can be found
+         * [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/protos/perfetto/metrics/).
+         *
+         * See
+         * [perfetto metric docs](https://perfetto.dev/docs/quickstart/trace-analysis#trace-based-metrics)
+         * for an overview on trace based metrics.
+         */
+        public fun queryMetricsProtoText(metrics: List<String>): String {
+            val computeResult =
+                queryAndVerifyMetricResult(metrics, ComputeMetricArgs.ResultFormat.TEXTPROTO)
+            check(computeResult.metrics_as_prototext != null)
+            return computeResult.metrics_as_prototext
+        }
+
+        private fun queryAndVerifyMetricResult(
+            metrics: List<String>,
+            format: ComputeMetricArgs.ResultFormat
+        ): ComputeMetricResult {
+            val nameString = metrics.joinToString()
+            require(metrics.none { it.contains(" ") }) {
+                "Metrics must not constain spaces, metrics: $nameString"
+            }
+
+            traceProcessor.tracer.trace("TraceProcessor#getTraceMetrics $nameString") {
+                require(traceProcessor.traceProcessorHttpServer.isRunning()) {
+                    "Perfetto trace_shell_process is not running."
+                }
+
+                // Compute metrics
+                val computeResult =
+                    traceProcessor.traceProcessorHttpServer.computeMetric(metrics, format)
+                if (computeResult.error != null) {
+                    throw IllegalStateException(computeResult.error)
+                }
+
+                return computeResult
+            }
+        }
+
+        /**
+         * Computes the given query on the currently loaded trace.
+         *
+         * Each row returned by a query is returned by the `Sequence` as a [Row]. To extract data
+         * from a `Row`, query by column name. The following example does this for name, timestamp,
+         * and duration of slices:
+         * ```
+         * // Runs the provided callback on each activityStart instance in the trace,
+         * // providing name, start timestamp (in ns) and duration (in ns)
+         * fun TraceProcessor.Session.forEachActivityStart(callback: (String, Long, Long) -> Unit) {
+         *     query("SELECT name,ts,dur FROM slice WHERE name LIKE \"activityStart\"").forEach {
+         *         callback(it.string("name"), it.long("ts"), it.long("dur")
+         *         // or, used as a map:
+         *         //callback(it["name"] as String, it["ts"] as Long, it["dur"] as Long)
+         *     }
+         * }
+         * ```
+         *
+         * @see TraceProcessor
+         * @see TraceProcessor.Session
+         */
+        public fun query(query: String): Sequence<Row> {
+            traceProcessor.tracer.trace("TraceProcessor#query $query") {
+                require(traceProcessor.traceProcessorHttpServer.isRunning()) {
+                    "Perfetto trace_shell_process is not running."
+                }
+                val queryResult =
+                    traceProcessor.traceProcessorHttpServer.rawQuery(query) {
+                        // Note: check for errors as part of decode, so it's immediate
+                        // instead of lazily in QueryResultIterator
+                        QueryResult.decodeAndCheckError(query, it)
+                    }
+                return Sequence { QueryResultIterator(queryResult) }
+            }
+        }
+
+        private fun QueryResult.Companion.decodeAndCheckError(
+            query: String,
+            inputStream: InputStream
+        ) =
+            ADAPTER.decode(inputStream).also {
+                check(it.error == null) {
+                    throw IllegalStateException("Error with query: --$query--, error=${it.error}")
+                }
+            }
+
+        /**
+         * Computes the given query on the currently loaded trace, returning the resulting protobuf
+         * bytes as a [ByteArray].
+         *
+         * Use [Session.query] if you do not wish to parse the Proto result yourself.
+         *
+         * The `QueryResult` protobuf definition can be found
+         * [in the Perfetto project](https://github.com/google/perfetto/blob/master/protos/perfetto/trace_processor/trace_processor.proto),
+         * which can be used to decode the result returned here with a protobuf parsing library.
+         *
+         * Note that this method does not check for errors in the protobuf, that is the caller's
+         * responsibility.
+         *
+         * @see Session.query
+         */
+        public fun rawQuery(query: String): ByteArray {
+            traceProcessor.tracer.trace("TraceProcessor#query $query") {
+                require(traceProcessor.traceProcessorHttpServer.isRunning()) {
+                    "Perfetto trace_shell_process is not running."
+                }
+                return traceProcessor.traceProcessorHttpServer.rawQuery(query) { it.readBytes() }
+            }
+        }
+
+        /**
+         * Query a trace for a list of slices - name, timestamp, and duration.
+         *
+         * Note that sliceNames may include wildcard matches, such as `foo%`
+         */
+        @RestrictTo(LIBRARY_GROUP) // Slice API not currently exposed, since it doesn't track table
+        public fun querySlices(
+            vararg sliceNames: String,
+            packageName: String?,
+        ): List<Slice> {
+            require(traceProcessor.traceProcessorHttpServer.isRunning()) {
+                "Perfetto trace_shell_process is not running."
+            }
+
+            val whereClause =
+                sliceNames.joinToString(
+                    separator = " OR ",
+                    prefix =
+                        if (packageName == null) {
+                            "("
+                        } else {
+                            processNameLikePkg(packageName) + " AND ("
+                        },
+                    postfix = ")"
+                ) {
+                    "slice_name LIKE \"$it\""
+                }
+            val innerJoins =
+                if (packageName != null) {
+                    """
+                INNER JOIN thread_track ON slice.track_id = thread_track.id
+                INNER JOIN thread USING(utid)
+                INNER JOIN process USING(upid)
+                """
+                        .trimMargin()
+                } else {
+                    ""
+                }
+
+            val processTrackInnerJoins =
+                """
+                INNER JOIN process_track ON slice.track_id = process_track.id
+                INNER JOIN process USING(upid)
+            """
+                    .trimIndent()
+
+            return query(
+                    query =
+                        """
+                    SELECT slice.name AS slice_name,ts,dur
+                    FROM slice
+                    $innerJoins
+                    WHERE $whereClause
+                    UNION
+                    SELECT process_track.name AS slice_name,ts,dur
+                    FROM slice
+                    $processTrackInnerJoins
+                    WHERE $whereClause
+                    ORDER BY ts
+                    """
+                            .trimIndent()
+                )
+                .map { row ->
+                    // Using an explicit mapper here to account for the aliasing of `slice_name`
+                    Slice(
+                        name = row.string("slice_name"),
+                        ts = row.long("ts"),
+                        dur = row.long("dur")
+                    )
+                }
+                .filter { it.dur != -1L } // filter out non-terminating slices
+                .toList()
+        }
+    }
+
+    @OptIn(ExperimentalTraceProcessorApi::class)
+    private val traceProcessorHttpServer: TraceProcessorHttpServer =
+        TraceProcessorHttpServer(serverLifecycleManager)
+    private var traceLoaded = false
+
+    private fun startServer(timeout: Duration): TraceProcessor =
+        tracer.trace("TraceProcessor#startServer") {
+            println("startserver($timeout)")
+            traceProcessorHttpServer.startServer(timeout)
+            return@trace this
+        }
+
+    private fun stopServer() =
+        tracer.trace("TraceProcessor#stopServer") {
+            println("stopserver")
+            traceProcessorHttpServer.stopServer()
+        }
+
+    /**
+     * Loads a trace in the current instance of the trace processor, clearing any previous loaded
+     * trace if existing.
+     */
+    private fun loadTraceImpl(absoluteTracePath: String) {
+        tracer.trace("TraceProcessor#loadTraceImpl") {
+            require(!absoluteTracePath.contains(" ")) {
+                "Trace path must not contain spaces: $absoluteTracePath"
+            }
+
+            val traceFile = File(absoluteTracePath)
+            require(traceFile.exists() && traceFile.isFile) {
+                "Trace path must exist and not be a directory: $absoluteTracePath"
+            }
+
+            // In case a previous trace was loaded, ensures to clear
+            if (traceLoaded) {
+                clearTrace()
+            }
+
+            val parseResults = traceProcessorHttpServer.parse(FileInputStream(traceFile))
+            parseResults.forEach { if (it.error != null) throw IllegalStateException(it.error) }
+
+            // Notifies the server that it won't receive any more trace parts
+            traceProcessorHttpServer.notifyEof()
+
+            traceLoaded = true
+        }
+    }
+
+    /** Clears the current loaded trace. */
+    private fun clearTrace() =
+        tracer.trace("TraceProcessor#clearTrace") {
+            traceProcessorHttpServer.restoreInitialTables()
+            traceLoaded = false
+        }
+}
+
+/** Helper for fuzzy matching process name to package */
+public fun processNameLikePkg(pkg: String): String {
+    // check for truncated package names, which can sometimes occur if perfetto can't capture full
+    // names, and only has 16 bytes from sched info (which results in 15 chars due to null
+    // termination)
+    val truncated =
+        if (pkg.length > 15) {
+            " OR process.name LIKE \"${pkg.takeLast(15)}\""
+        } else {
+            ""
+        }
+    return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%"$truncated)"""
+}
diff --git a/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessorHttpServer.kt b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessorHttpServer.kt
new file mode 100644
index 0000000..f586b2c
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/commonMain/kotlin/androidx/benchmark/traceprocessor/TraceProcessorHttpServer.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+import java.io.FileNotFoundException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.ConnectException
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+import perfetto.protos.AppendTraceDataResult
+import perfetto.protos.ComputeMetricArgs
+import perfetto.protos.ComputeMetricResult
+import perfetto.protos.QueryArgs
+import perfetto.protos.StatusResult
+
+@OptIn(ExperimentalTraceProcessorApi::class)
+internal class TraceProcessorHttpServer(
+    private val serverLifecycleManager: ServerLifecycleManager
+) {
+    companion object {
+        private const val HTTP_ADDRESS = "http://localhost"
+        private const val METHOD_GET = "GET"
+        private const val METHOD_POST = "POST"
+        private const val PATH_QUERY = "/query"
+        private const val PATH_COMPUTE_METRIC = "/compute_metric"
+        private const val PATH_PARSE = "/parse"
+        private const val PATH_NOTIFY_EOF = "/notify_eof"
+        private const val PATH_STATUS = "/status"
+        private const val PATH_RESTORE_INITIAL_TABLES = "/restore_initial_tables"
+        private val WAIT_INTERVAL = 5.milliseconds
+        private val READ_TIMEOUT = 30.seconds
+
+        // Note that trace processor http server has a hard limit of 64Mb for payload size.
+        // https://cs.android.com/android/platform/superproject/+/master:external/perfetto/src/base/http/http_server.cc;l=33
+        private const val PARSE_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16Mb
+    }
+
+    private var hasStarted = false
+    private var port: Int = 9001 // todo: track better
+
+    /**
+     * Blocking method that runs the perfetto trace_shell_processor in server mode.
+     *
+     * @throws IllegalStateException if the server is not running by the end of the timeout.
+     */
+    @Suppress("BanThreadSleep") // needed for awaiting trace processor instance
+    fun startServer(timeout: Duration) {
+        if (hasStarted) {
+            log("Tried to start a trace shell processor that is already running.")
+        } else {
+            port = serverLifecycleManager.start()
+            // Wait for the trace_processor_shell server to start.
+            var elapsed = 0.milliseconds
+            while (!isRunning()) {
+                Thread.sleep(WAIT_INTERVAL.toLong(DurationUnit.MILLISECONDS))
+                elapsed += WAIT_INTERVAL
+                if (elapsed >= timeout) {
+                    throw IllegalStateException(serverLifecycleManager.timeoutMessage())
+                }
+            }
+
+            hasStarted = true
+
+            log("Perfetto trace processor shell server started (port=$port).")
+        }
+    }
+
+    /** Stops the server killing the associated process */
+    fun stopServer() {
+        serverLifecycleManager.stop()
+    }
+
+    /** Returns true whether the server is running, false otherwise. */
+    fun isRunning(): Boolean {
+        return try {
+            val statusResult = status()
+            return statusResult.api_version != null && statusResult.api_version > 0
+        } catch (e: ConnectException) {
+            // Note that this is fired when the server port is not bound yet.
+            // This can happen before the perfetto trace processor server is fully started.
+            false
+        } catch (e: FileNotFoundException) {
+            // Note that this is fired when the endpoint queried does not exist.
+            // This can happen before the perfetto trace processor server is fully started.
+            false
+        }
+    }
+
+    /**
+     * Executes the given [sqlQuery] on a previously parsed trace with custom decoding.
+     *
+     * Note that this does not decode the query result, so it's the caller's responsibility to check
+     * for errors in the result.
+     */
+    fun <T> rawQuery(sqlQuery: String, decodeBlock: (InputStream) -> T): T =
+        httpRequest(
+            method = METHOD_POST,
+            url = PATH_QUERY,
+            encodeBlock = { QueryArgs.ADAPTER.encode(it, QueryArgs(sqlQuery)) },
+            decodeBlock = decodeBlock
+        )
+
+    /** Computes the given metrics on a previously parsed trace. */
+    fun computeMetric(
+        metrics: List<String>,
+        resultFormat: ComputeMetricArgs.ResultFormat
+    ): ComputeMetricResult =
+        httpRequest(
+            method = METHOD_POST,
+            url = PATH_COMPUTE_METRIC,
+            encodeBlock = {
+                ComputeMetricArgs.ADAPTER.encode(it, ComputeMetricArgs(metrics, resultFormat))
+            },
+            decodeBlock = { ComputeMetricResult.ADAPTER.decode(it) }
+        )
+
+    /**
+     * Parses the trace file in chunks. Note that [notifyEof] should be called at the end to let the
+     * processor know that no more chunks will be sent.
+     */
+    fun parse(inputStream: InputStream): List<AppendTraceDataResult> {
+        val responses = mutableListOf<AppendTraceDataResult>()
+        while (true) {
+            val buffer = ByteArray(PARSE_PAYLOAD_SIZE)
+            val read = inputStream.read(buffer)
+            if (read <= 0) break
+            responses.add(
+                httpRequest(
+                    method = METHOD_POST,
+                    url = PATH_PARSE,
+                    encodeBlock = { it.write(buffer, 0, read) },
+                    decodeBlock = { AppendTraceDataResult.ADAPTER.decode(it) }
+                )
+            )
+        }
+        return responses
+    }
+
+    /** Notifies that the entire trace has been uploaded and no more chunks will be sent. */
+    fun notifyEof() =
+        httpRequest(
+            method = METHOD_GET,
+            url = PATH_NOTIFY_EOF,
+            encodeBlock = null,
+            decodeBlock = {}
+        )
+
+    /** Clears the loaded trace and restore the state of the initial tables */
+    fun restoreInitialTables() =
+        httpRequest(
+            method = METHOD_GET,
+            url = PATH_RESTORE_INITIAL_TABLES,
+            encodeBlock = null,
+            decodeBlock = {}
+        )
+
+    /** Checks the status of the trace_shell_processor http server. */
+    private fun status(): StatusResult =
+        httpRequest(
+            method = METHOD_GET,
+            url = PATH_STATUS,
+            encodeBlock = null,
+            decodeBlock = { StatusResult.ADAPTER.decode(it) }
+        )
+
+    private fun <T> httpRequest(
+        method: String,
+        url: String,
+        contentType: String = "application/octet-stream",
+        encodeBlock: ((OutputStream) -> Unit)?,
+        decodeBlock: ((InputStream) -> T)
+    ): T {
+        with(URL("$HTTP_ADDRESS:${port}$url").openConnection() as HttpURLConnection) {
+            requestMethod = method
+            readTimeout = READ_TIMEOUT.toInt(DurationUnit.MILLISECONDS)
+            setRequestProperty("Content-Type", contentType)
+            if (encodeBlock != null) {
+                doOutput = true
+                encodeBlock(outputStream)
+                outputStream.close()
+            }
+            val value = decodeBlock(inputStream)
+            if (responseCode != 200) {
+                throw IllegalStateException(responseMessage)
+            }
+            return value
+        }
+    }
+}
diff --git a/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/androidx/benchmark/traceprocessor/Log.jvm.kt b/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/androidx/benchmark/traceprocessor/Log.jvm.kt
new file mode 100644
index 0000000..c1bbb88
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/androidx/benchmark/traceprocessor/Log.jvm.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 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 androidx.benchmark.traceprocessor
+
+internal actual fun log(string: String) {
+    println("TraceProcessor: $string")
+}
diff --git a/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/perfetto/protos/package-info.java b/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/perfetto/protos/package-info.java
new file mode 100644
index 0000000..02661a3
--- /dev/null
+++ b/benchmark/benchmark-traceprocessor/src/jvmMain/kotlin/perfetto/protos/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * Hide the perfetto.protos package, as it's an implementation detail of benchmark
+ *
+ * Note: To work around b/382741383, this file is present in both jvmMain/ and androidMain/
+ *
+ * Note: other attempts to use these protos in a benchmark process may clash with our
+ * definitions. If this becomes an issue, we can move ours to a separate, internal package.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+package perfetto.protos;
+
+import androidx.annotation.RestrictTo;
diff --git a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmark.kt b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmark.kt
index b3b309d..326ebe5 100644
--- a/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmark.kt
+++ b/benchmark/benchmark/src/androidTest/java/androidx/benchmark/benchmark/TrivialKotlinBenchmark.kt
@@ -16,6 +16,7 @@
 
 package androidx.benchmark.benchmark
 
+import android.annotation.SuppressLint
 import androidx.benchmark.junit4.BenchmarkRule
 import androidx.benchmark.junit4.measureRepeated
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -29,7 +30,9 @@
 class TrivialKotlinBenchmark {
     @get:Rule val benchmarkRule = BenchmarkRule()
 
-    @Test fun nothing() = benchmarkRule.measureRepeated {}
+    @SuppressLint("BanThreadSleep") // intentional bad behavior / regression
+    @Test
+    fun nothing() = benchmarkRule.measureRepeated { Thread.sleep(1) }
 
     @Test
     fun increment() {
diff --git a/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/PerfettoTraceProcessorBenchmark.kt b/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/PerfettoTraceProcessorBenchmark.kt
deleted file mode 100644
index b06e13a..0000000
--- a/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/PerfettoTraceProcessorBenchmark.kt
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- * Copyright 2022 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 androidx.benchmark.integration.macrobenchmark
-
-import androidx.benchmark.Outputs
-import androidx.benchmark.macro.ExperimentalMetricApi
-import androidx.benchmark.macro.TraceSectionMetric
-import androidx.benchmark.macro.junit4.MacrobenchmarkRule
-import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
-import androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi
-import androidx.benchmark.perfetto.PerfettoHelper
-import androidx.benchmark.perfetto.PerfettoTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.test.filters.SdkSuppress
-import androidx.test.filters.SmallTest
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.tracing.trace
-import java.io.File
-import org.junit.Assume
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-
-@SmallTest
-@SdkSuppress(minSdkVersion = 29)
-@OptIn(
-    ExperimentalMetricApi::class,
-    ExperimentalPerfettoTraceProcessorApi::class,
-    ExperimentalPerfettoCaptureApi::class
-)
-class PerfettoTraceProcessorBenchmark {
-
-    @get:Rule val benchmarkRule = MacrobenchmarkRule()
-
-    private val traceFile = createTempFileFromAsset("api32_startup_warm", ".perfetto-trace")
-
-    @Before fun setUp() = Assume.assumeTrue(PerfettoHelper.isAbiSupported())
-
-    @Test
-    fun loadServer() =
-        benchmarkRule.measureRepeated(
-            packageName = PACKAGE_NAME,
-            metrics = measureBlockMetric,
-            iterations = 5,
-        ) {
-            measureBlock { PerfettoTraceProcessor.runServer {} }
-        }
-
-    @Test
-    fun singleTrace() =
-        benchmarkRule.measureRepeated(
-            packageName = PACKAGE_NAME,
-            metrics = measureBlockMetric,
-            iterations = 5,
-        ) {
-            measureBlock {
-                PerfettoTraceProcessor.runServer {
-                    loadTrace(PerfettoTrace(traceFile.absolutePath)) {}
-                }
-            }
-        }
-
-    @Test
-    fun doubleTrace() =
-        benchmarkRule.measureRepeated(
-            packageName = PACKAGE_NAME,
-            metrics = measureBlockMetric,
-            iterations = 5,
-        ) {
-            measureBlock {
-                PerfettoTraceProcessor.runServer {
-                    loadTrace(PerfettoTrace(traceFile.absolutePath)) {}
-                    loadTrace(PerfettoTrace(traceFile.absolutePath)) {}
-                }
-            }
-        }
-
-    @Test fun computeSingleMetric() = benchmarkWithTrace { runComputeStartupMetric() }
-
-    @Test fun executeSingleSliceQuery() = benchmarkWithTrace { runSlicesQuery() }
-
-    @Test
-    fun executeMultipleQueries() = benchmarkWithTrace {
-        runSlicesQuery()
-        runCounterQuery()
-        runProcessQuery()
-    }
-
-    @Test
-    fun executeMultipleQueriesAndComputeMetric() = benchmarkWithTrace {
-        runComputeStartupMetric()
-        runSlicesQuery()
-        runCounterQuery()
-        runProcessQuery()
-    }
-
-    private fun benchmarkWithTrace(block: PerfettoTraceProcessor.Session.() -> Unit) =
-        benchmarkRule.measureRepeated(
-            packageName = PACKAGE_NAME,
-            metrics = measureBlockMetric,
-            iterations = 5,
-        ) {
-            measureBlock {
-                // This will run perfetto trace processor http server on the specified port 10555.
-                // Note that this is an arbitrary number and the default cannot be used because
-                // the macrobenchmark instance of the server is running at the same time.
-                PerfettoTraceProcessor.runSingleSessionServer(
-                    absoluteTracePath = traceFile.absolutePath,
-                    block = block
-                )
-            }
-        }
-
-    private fun PerfettoTraceProcessor.Session.runComputeStartupMetric() {
-        getTraceMetrics("android_startup")
-    }
-
-    private fun PerfettoTraceProcessor.Session.runSlicesQuery() {
-        query(
-            """
-                SELECT slice.name, slice.ts, slice.dur, thread_track.id, thread_track.name
-                FROM slice
-                INNER JOIN thread_track on slice.track_id = thread_track.id
-                INNER JOIN thread USING(utid)
-                INNER JOIN process USING(upid)
-            """
-                .trimIndent()
-        )
-    }
-
-    private fun PerfettoTraceProcessor.Session.runCounterQuery() {
-        query(
-            """
-                SELECT track.name, counter.value, counter.ts
-                FROM track
-                JOIN counter ON track.id = counter.track_id
-            """
-                .trimIndent()
-        )
-    }
-
-    private fun PerfettoTraceProcessor.Session.runProcessQuery() {
-        query(
-            """
-                SELECT upid
-                FROM counter
-                JOIN process_counter_track ON process_counter_track.id = counter.track_id
-                WHERE process_counter_track.name = 'mem.swap' AND value > 1000
-            """
-                .trimIndent()
-        )
-    }
-
-    private fun createTempFileFromAsset(prefix: String, suffix: String): File {
-        val file = File.createTempFile(prefix, suffix, Outputs.dirUsableByAppAndShell)
-        InstrumentationRegistry.getInstrumentation()
-            .context
-            .assets
-            .open(prefix + suffix)
-            .copyTo(file.outputStream())
-        return file
-    }
-
-    companion object {
-        private const val PACKAGE_NAME = "androidx.benchmark.integration.macrobenchmark.target"
-        private const val SECTION_NAME = "PerfettoTraceProcessorBenchmark"
-
-        /** Measures single call to [measureBlock] function */
-        private val measureBlockMetric =
-            listOf(
-                TraceSectionMetric(
-                    sectionName = SECTION_NAME,
-                    targetPackageOnly = false // tracing in test process, not target app
-                )
-            )
-
-        /** This block is measured by [measureBlockMetric] */
-        internal inline fun <T> measureBlock(block: () -> T): T = trace(SECTION_NAME) { block() }
-    }
-}
diff --git a/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/TraceProcessorBenchmark.kt b/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/TraceProcessorBenchmark.kt
new file mode 100644
index 0000000..ba068a9
--- /dev/null
+++ b/benchmark/integration-tests/macrobenchmark/src/main/java/androidx/benchmark/integration/macrobenchmark/TraceProcessorBenchmark.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2022 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 androidx.benchmark.integration.macrobenchmark
+
+import androidx.benchmark.Outputs
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.TraceSectionMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.benchmark.macro.runServer
+import androidx.benchmark.macro.runSingleSessionServer
+import androidx.benchmark.perfetto.PerfettoHelper
+import androidx.benchmark.traceprocessor.PerfettoTrace
+import androidx.benchmark.traceprocessor.TraceProcessor
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.tracing.trace
+import java.io.File
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@SmallTest
+@SdkSuppress(minSdkVersion = 29)
+@OptIn(ExperimentalMetricApi::class)
+class TraceProcessorBenchmark {
+    @get:Rule val benchmarkRule = MacrobenchmarkRule()
+
+    private val traceFile = createTempFileFromAsset("api32_startup_warm", ".perfetto-trace")
+
+    @Before fun setUp() = Assume.assumeTrue(PerfettoHelper.isAbiSupported())
+
+    @Test
+    fun loadServer() =
+        benchmarkRule.measureRepeated(
+            packageName = PACKAGE_NAME,
+            metrics = measureBlockMetric,
+            iterations = 5,
+        ) {
+            measureBlock { TraceProcessor.runServer {} }
+        }
+
+    @Test
+    fun singleTrace() =
+        benchmarkRule.measureRepeated(
+            packageName = PACKAGE_NAME,
+            metrics = measureBlockMetric,
+            iterations = 5,
+        ) {
+            measureBlock {
+                TraceProcessor.runServer { loadTrace(PerfettoTrace(traceFile.absolutePath)) {} }
+            }
+        }
+
+    @Test
+    fun doubleTrace() =
+        benchmarkRule.measureRepeated(
+            packageName = PACKAGE_NAME,
+            metrics = measureBlockMetric,
+            iterations = 5,
+        ) {
+            measureBlock {
+                TraceProcessor.runServer {
+                    loadTrace(PerfettoTrace(traceFile.absolutePath)) {}
+                    loadTrace(PerfettoTrace(traceFile.absolutePath)) {}
+                }
+            }
+        }
+
+    @Test fun computeSingleMetric() = benchmarkWithTrace { runComputeStartupMetric() }
+
+    @Test fun executeSingleSliceQuery() = benchmarkWithTrace { runSlicesQuery() }
+
+    @Test
+    fun executeMultipleQueries() = benchmarkWithTrace {
+        runSlicesQuery()
+        runCounterQuery()
+        runProcessQuery()
+    }
+
+    @Test
+    fun executeMultipleQueriesAndComputeMetric() = benchmarkWithTrace {
+        runComputeStartupMetric()
+        runSlicesQuery()
+        runCounterQuery()
+        runProcessQuery()
+    }
+
+    private fun benchmarkWithTrace(block: TraceProcessor.Session.() -> Unit) =
+        benchmarkRule.measureRepeated(
+            packageName = PACKAGE_NAME,
+            metrics = measureBlockMetric,
+            iterations = 5,
+        ) {
+            measureBlock {
+                // This will run perfetto trace processor http server on the specified port 10555.
+                // Note that this is an arbitrary number and the default cannot be used because
+                // the macrobenchmark instance of the server is running at the same time.
+                TraceProcessor.runSingleSessionServer(
+                    absoluteTracePath = traceFile.absolutePath,
+                    block = block
+                )
+            }
+        }
+
+    private fun TraceProcessor.Session.runComputeStartupMetric() {
+        getTraceMetrics("android_startup")
+    }
+
+    private fun TraceProcessor.Session.runSlicesQuery() {
+        query(
+            """
+                SELECT slice.name, slice.ts, slice.dur, thread_track.id, thread_track.name
+                FROM slice
+                INNER JOIN thread_track on slice.track_id = thread_track.id
+                INNER JOIN thread USING(utid)
+                INNER JOIN process USING(upid)
+            """
+                .trimIndent()
+        )
+    }
+
+    private fun TraceProcessor.Session.runCounterQuery() {
+        query(
+            """
+                SELECT track.name, counter.value, counter.ts
+                FROM track
+                JOIN counter ON track.id = counter.track_id
+            """
+                .trimIndent()
+        )
+    }
+
+    private fun TraceProcessor.Session.runProcessQuery() {
+        query(
+            """
+                SELECT upid
+                FROM counter
+                JOIN process_counter_track ON process_counter_track.id = counter.track_id
+                WHERE process_counter_track.name = 'mem.swap' AND value > 1000
+            """
+                .trimIndent()
+        )
+    }
+
+    private fun createTempFileFromAsset(prefix: String, suffix: String): File {
+        val file = File.createTempFile(prefix, suffix, Outputs.dirUsableByAppAndShell)
+        InstrumentationRegistry.getInstrumentation()
+            .context
+            .assets
+            .open(prefix + suffix)
+            .copyTo(file.outputStream())
+        return file
+    }
+
+    companion object {
+        private const val PACKAGE_NAME = "androidx.benchmark.integration.macrobenchmark.target"
+        private const val SECTION_NAME = "TraceProcessorBenchmark"
+
+        /** Measures single call to [measureBlock] function */
+        private val measureBlockMetric =
+            listOf(
+                TraceSectionMetric(
+                    sectionName = SECTION_NAME,
+                    targetPackageOnly = false // tracing in test process, not target app
+                )
+            )
+
+        /** This block is measured by [measureBlockMetric] */
+        internal inline fun <T> measureBlock(block: () -> T): T = trace(SECTION_NAME) { block() }
+    }
+}
diff --git a/buildSrc-tests/lint-baseline.xml b/buildSrc-tests/lint-baseline.xml
index 406a7467..f24bb50 100644
--- a/buildSrc-tests/lint-baseline.xml
+++ b/buildSrc-tests/lint-baseline.xml
@@ -220,15 +220,6 @@
     <issue
         id="GradleProjectIsolation"
         message="Use isolated.rootProject instead of getRootProject"
-        errorLine1="    rootProject.extensions.findByType&lt;NodeJsRootExtension>()?.let { nodeJs ->"
-        errorLine2="    ~~~~~~~~~~~">
-        <location
-            file="${:buildSrc-tests*main*MAIN*sourceProvider*0*javaDir*4}/androidx/build/AndroidXMultiplatformExtension.kt"/>
-    </issue>
-
-    <issue
-        id="GradleProjectIsolation"
-        message="Use isolated.rootProject instead of getRootProject"
         errorLine1="    rootProject.extensions.findByType(YarnRootExtension::class.java)?.let { yarn ->"
         errorLine2="    ~~~~~~~~~~~">
         <location
@@ -436,15 +427,6 @@
     <issue
         id="GradleProjectIsolation"
         message="Use isolated.rootProject instead of getRootProject"
-        errorLine1="        project.rootProject.gradle.sharedServices.registerIfAbsent("
-        errorLine2="                ~~~~~~~~~~~">
-        <location
-            file="${:buildSrc-tests*main*MAIN*sourceProvider*0*javaDir*4}/androidx/build/ProjectParser.kt"/>
-    </issue>
-
-    <issue
-        id="GradleProjectIsolation"
-        message="Use isolated.rootProject instead of getRootProject"
         errorLine1="                regenerate(project.rootProject, groupId, artifactId, artifactVersion, location)"
         errorLine2="                                   ~~~~~~~~~~~">
         <location
diff --git a/buildSrc-tests/src/test/java/androidx/build/testConfiguration/AndroidTestConfigBuilderTest.kt b/buildSrc-tests/src/test/java/androidx/build/testConfiguration/AndroidTestConfigBuilderTest.kt
index 530d3e9..ab979b6 100644
--- a/buildSrc-tests/src/test/java/androidx/build/testConfiguration/AndroidTestConfigBuilderTest.kt
+++ b/buildSrc-tests/src/test/java/androidx/build/testConfiguration/AndroidTestConfigBuilderTest.kt
@@ -38,6 +38,7 @@
         builder = ConfigBuilder()
         builder
             .configName("placeHolderAndroidTest.xml")
+            .configType(TestConfigType.DEFAULT)
             .isMicrobenchmark(false)
             .applicationId("com.androidx.placeholder.Placeholder")
             .isPostsubmit(true)
@@ -55,8 +56,23 @@
 
     @Test
     fun testXmlAgainstGoldenMainSandboxConfiguration() {
-        builder.initialSetupApks(listOf("init-placeholder.apk"))
-        builder.enablePrivacySandbox(true)
+        builder.appApksModel(
+            AppApksModel(
+                apkGroups =
+                    listOf(
+                        singleFileApkFileGroup("init-placeholder.apk"),
+                        ApkFileGroup(
+                            apks =
+                                listOf(
+                                    ApkFile(name = "app.apk"),
+                                    ApkFile(name = "split1.apk"),
+                                    ApkFile(name = "split2.apk")
+                                )
+                        ),
+                    )
+            )
+        )
+        builder.configType(TestConfigType.PRIVACY_SANDBOX_MAIN)
         MatcherAssert.assertThat(
             builder.buildXml(),
             CoreMatchers.`is`(goldenConfigForMainSandboxConfiguration)
@@ -64,13 +80,6 @@
     }
 
     @Test
-    fun testXmlAgainstGoldenWithSplits() {
-        builder.appApkName("app.apk")
-        builder.appSplits(listOf("split1.apk", "split2.apk"))
-        MatcherAssert.assertThat(builder.buildXml(), CoreMatchers.`is`(goldenConfigWithSplits))
-    }
-
-    @Test
     fun testXmlAgainstGoldenMicrobenchmark() {
         builder.isMicrobenchmark(true)
 
@@ -91,8 +100,7 @@
         builder.isMacrobenchmark(true)
         builder.instrumentationArgsMap["androidx.test.argument1"] = "something1"
         builder.instrumentationArgsMap["androidx.test.argument2"] = "something2"
-        builder.appApkSha256("654321")
-        builder.appApkName("targetApp.apk")
+        builder.appApksModel(singleFileAppApksModel(name = "targetApp.apk", sha256 = "654321"))
 
         // NOTE: blocklisted arg is removed
         builder.instrumentationArgsMap["androidx.benchmark.profiling.skipWhenDurationRisksAnr"] =
@@ -104,6 +112,37 @@
     }
 
     @Test
+    fun testXmlAgainstGoldenMainSandboxMacroBenchmark() {
+        builder.isMacrobenchmark(true)
+        builder.instrumentationArgsMap["androidx.test.argument1"] = "something1"
+        builder.instrumentationArgsMap["androidx.test.argument2"] = "something2"
+        builder.appApksModel(
+            AppApksModel(
+                apkGroups =
+                    listOf(
+                        singleFileApkFileGroup(name = "targetSdk.apk", sha256 = "1"),
+                        ApkFileGroup(
+                            apks =
+                                listOf(
+                                    ApkFile(name = "targetApp.apk", sha256 = "2"),
+                                    ApkFile(name = "targetAppSplit.apk", sha256 = "3")
+                                )
+                        ),
+                    )
+            )
+        )
+        builder.configType(TestConfigType.PRIVACY_SANDBOX_MAIN)
+
+        // NOTE: blocklisted arg is removed
+        builder.instrumentationArgsMap["androidx.benchmark.profiling.skipWhenDurationRisksAnr"] =
+            "true"
+        MatcherAssert.assertThat(
+            builder.buildXml(),
+            CoreMatchers.`is`(goldenConfigForMainSandboxMacroBenchmark)
+        )
+    }
+
+    @Test
     fun testJsonAgainstGoldenDefault() {
         builder.instrumentationArgsMap["androidx.test.argument1"] = "something1"
         builder.instrumentationArgsMap["androidx.test.argument2"] = "something2"
@@ -207,7 +246,9 @@
 
     @Test
     fun testJsonAgainstAppTestGolden() {
-        builder.appApkName("app-placeholder.apk").appApkSha256("654321")
+        builder.appApksModel(
+            singleFileAppApksModel(name = "app-placeholder.apk", sha256 = "654321")
+        )
         MatcherAssert.assertThat(
             builder.buildJson(),
             CoreMatchers.`is`(
@@ -299,13 +340,13 @@
 
     @Test
     fun testValidTestConfigXml_withAppApk() {
-        builder.appApkName("Placeholder.apk")
+        builder.appApksModel(singleFileAppApksModel(name = "Placeholder.apk"))
         validate(builder.buildXml())
     }
 
     @Test
     fun testValidTestConfigXml_presubmitWithAppApk() {
-        builder.isPostsubmit(false).appApkName("Placeholder.apk")
+        builder.isPostsubmit(false).appApksModel(singleFileAppApksModel(name = "Placeholder.apk"))
         validate(builder.buildXml())
     }
 
@@ -395,12 +436,14 @@
     <option name="config-descriptor:metadata" key="applicationId" value="com.androidx.placeholder.Placeholder" />
     <option name="wifi:disable" value="true" />
     <option name="instrumentation-arg" key="notAnnotation" value="androidx.test.filters.FlakyTest" />
+    <option name="instrumentation-arg" key="androidx.testConfigType" value="PRIVACY_SANDBOX_MAIN" />
     <include name="google/unbundled/common/setup" />
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
     <option name="cleanup-apks" value="true" />
     <option name="install-arg" value="-t" />
-    <option name="test-file-name" value="init-placeholder.apk" />
     <option name="test-file-name" value="placeholder.apk" />
+    <option name="test-file-name" value="init-placeholder.apk" />
+    <option name="split-apk-file-names" value="app.apk,split1.apk,split2.apk" />
     </target_preparer>
     <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
     <option name="run-command" value="cmd sdk_sandbox set-state --enabled"/>
@@ -416,42 +459,6 @@
 """
         .trimIndent()
 
-private val goldenConfigWithSplits =
-    """
-    <?xml version="1.0" encoding="utf-8"?>
-    <!-- Copyright (C) 2020 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.-->
-    <configuration description="Runs tests for the module">
-    <object type="module_controller" class="com.android.tradefed.testtype.suite.module.MinApiLevelModuleController">
-    <option name="min-api-level" value="15" />
-    </object>
-    <option name="test-suite-tag" value="placeholder_tag" />
-    <option name="config-descriptor:metadata" key="applicationId" value="com.androidx.placeholder.Placeholder" />
-    <option name="wifi:disable" value="true" />
-    <option name="instrumentation-arg" key="notAnnotation" value="androidx.test.filters.FlakyTest" />
-    <include name="google/unbundled/common/setup" />
-    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
-    <option name="cleanup-apks" value="true" />
-    <option name="install-arg" value="-t" />
-    <option name="test-file-name" value="placeholder.apk" />
-    <option name="split-apk-file-names" value="app.apk,split1.apk,split2.apk" />
-    </target_preparer>
-    <test class="com.android.tradefed.testtype.AndroidJUnitTest">
-    <option name="runner" value="com.example.Runner"/>
-    <option name="package" value="com.androidx.placeholder.Placeholder" />
-    </test>
-    </configuration>
-"""
-        .trimIndent()
-
 private val goldenDefaultConfigBenchmark =
     """
     <?xml version="1.0" encoding="utf-8"?>
@@ -537,3 +544,54 @@
     </configuration>
 """
         .trimIndent()
+
+private val goldenConfigForMainSandboxMacroBenchmark =
+    """
+    <?xml version="1.0" encoding="utf-8"?>
+    <!-- Copyright (C) 2020 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.-->
+    <configuration description="Runs tests for the module">
+    <object type="module_controller" class="com.android.tradefed.testtype.suite.module.MinApiLevelModuleController">
+    <option name="min-api-level" value="15" />
+    </object>
+    <option name="test-suite-tag" value="placeholder_tag" />
+    <option name="config-descriptor:metadata" key="applicationId" value="com.androidx.placeholder.Placeholder" />
+    <option name="wifi:disable" value="true" />
+    <option name="instrumentation-arg" key="notAnnotation" value="androidx.test.filters.FlakyTest" />
+    <option name="instrumentation-arg" key="androidx.test.argument1" value="something1" />
+    <option name="instrumentation-arg" key="androidx.test.argument2" value="something2" />
+    <option name="instrumentation-arg" key="androidx.benchmark.output.payload.testApkSha256" value="123456" />
+    <option name="instrumentation-arg" key="androidx.benchmark.output.payload.appApkSha256" value="a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" />
+    <option name="instrumentation-arg" key="androidx.benchmark.enabledRules" value="Macrobenchmark" />
+    <option name="instrumentation-arg" key="androidx.testConfigType" value="PRIVACY_SANDBOX_MAIN" />
+    <include name="google/unbundled/common/setup" />
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+    <option name="cleanup-apks" value="true" />
+    <option name="install-arg" value="-t" />
+    <option name="test-file-name" value="placeholder.apk" />
+    <option name="test-file-name" value="targetSdk.apk" />
+    <option name="split-apk-file-names" value="targetApp.apk,targetAppSplit.apk" />
+    </target_preparer>
+    <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+    <option name="run-command" value="cmd sdk_sandbox set-state --enabled"/>
+    <option name="run-command" value="device_config set_sync_disabled_for_tests persistent" />
+    <option name="teardown-command" value="cmd sdk_sandbox set-state --reset"/>
+    <option name="teardown-command" value="device_config set_sync_disabled_for_tests none" />
+    </target_preparer>
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+    <option name="runner" value="com.example.Runner"/>
+    <option name="package" value="com.androidx.placeholder.Placeholder" />
+    <option name="device-listeners" value="androidx.benchmark.macro.junit4.InstrumentationResultsRunListener" />
+    <option name="device-listeners" value="androidx.benchmark.macro.junit4.SideEffectRunListener" />
+    </test>
+    </configuration>
+"""
+        .trimIndent()
diff --git a/buildSrc/imports/privacysandbox-gradle-plugin/build.gradle b/buildSrc/imports/privacysandbox-gradle-plugin/build.gradle
new file mode 100644
index 0000000..b90b78b
--- /dev/null
+++ b/buildSrc/imports/privacysandbox-gradle-plugin/build.gradle
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2024 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.
+ */
+
+apply from: "../../shared.gradle"
+apply plugin: "java-gradle-plugin"
+
+sourceSets {
+    main.java.srcDirs += "${supportRootFolder}/privacysandbox/plugins/plugins-privacysandbox-library/src/main/java"
+}
+
+gradlePlugin {
+    plugins {
+        privacysandboxplugin {
+            id = "androidx.privacysandboxplugin"
+            implementationClass = "androidx.privacysandboxlibraryplugin.PrivacySandboxLibraryPlugin"
+        }
+    }
+}
+
+validatePlugins {
+    enableStricterValidation = true
+}
diff --git a/buildSrc/karmaconfig/karma.conf.js b/buildSrc/karmaconfig/karma.conf.js
index 9d1d211..2624c3c 100644
--- a/buildSrc/karmaconfig/karma.conf.js
+++ b/buildSrc/karmaconfig/karma.conf.js
@@ -1,6 +1,6 @@
 // Set a fairly long test timeout because some tests in collection
 // (specifically insertManyRemoveMany) occasionally take 20+ seconds to complete.
-var testTimeoutInMs = 10000 * 30
+var testTimeoutInMs = 1000 * 30
 // disconnect timeout should be longer than test timeout so we don't disconnect before the timeout
 // is reported.
 var browserDisconnectTimeoutInMs = testTimeoutInMs + 5000
@@ -8,11 +8,23 @@
   // https://karma-runner.github.io/6.4/config/configuration-file.html
     browserDisconnectTimeout: browserDisconnectTimeoutInMs,
     processKillTimeout: testTimeoutInMs,
-    concurrency: 1,
-    logLevel: config.LOG_DEBUG,
+    concurrency: 10,
     client: {
       mocha: {
         timeout: testTimeoutInMs
       }
     }
-})
+});
+
+// Add 5 second delay exit to ensure log flushing. This is needed for Kotlin to avoid flakiness when
+// marking a test as complete. See (b/382336155)
+// Remove when https://youtrack.jetbrains.com/issue/KT-73911/ is resolved.
+(function() {
+  const originalExit = process.exit;
+  process.exit = function(code) {
+    console.log('Delaying exit for logs...');
+    setTimeout(() => {
+      originalExit(code);
+    }, 5000);
+  };
+})();
diff --git a/buildSrc/plugins/build.gradle b/buildSrc/plugins/build.gradle
index 64cc36d..cdeba21 100644
--- a/buildSrc/plugins/build.gradle
+++ b/buildSrc/plugins/build.gradle
@@ -2,14 +2,15 @@
 
 dependencies {
     implementation(project(":public"))
-    api project(":imports:baseline-profile-gradle-plugin")
-    api project(":imports:benchmark-darwin-plugin")
-    api project(":imports:benchmark-gradle-plugin")
-    api project(":imports:glance-layout-generator")
-    api project(":imports:inspection-gradle-plugin")
-    api project(":imports:room-gradle-plugin")
-    api project(":imports:stableaidl-gradle-plugin")
-    api project(":imports:binary-compatibility-validator")
+    api(project(":imports:baseline-profile-gradle-plugin"))
+    api(project(":imports:benchmark-darwin-plugin"))
+    api(project(":imports:benchmark-gradle-plugin"))
+    api(project(":imports:binary-compatibility-validator"))
+    api(project(":imports:glance-layout-generator"))
+    api(project(":imports:inspection-gradle-plugin"))
+    api(project(":imports:privacysandbox-gradle-plugin"))
+    api(project(":imports:room-gradle-plugin"))
+    api(project(":imports:stableaidl-gradle-plugin"))
 }
 
 apply from: "../shared.gradle"
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index 921b41a..84178ef 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
@@ -50,6 +50,9 @@
 import com.android.build.api.attributes.BuildTypeAttr
 import com.android.build.api.dsl.ApplicationExtension
 import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.dsl.KotlinMultiplatformAndroidDeviceTestCompilation
+import com.android.build.api.dsl.KotlinMultiplatformAndroidHostTestCompilation
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.dsl.LibraryExtension
 import com.android.build.api.dsl.PrivacySandboxSdkExtension
 import com.android.build.api.dsl.TestBuildType
@@ -682,10 +685,9 @@
         project.configureJavaCompilationWarnings(androidXExtension)
     }
 
-    @Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
     private fun configureWithKotlinMultiplatformAndroidPlugin(
         project: Project,
-        kotlinMultiplatformAndroidTarget: DeprecatedKotlinMultiplatformAndroidTarget,
+        kotlinMultiplatformAndroidTarget: KotlinMultiplatformAndroidLibraryTarget,
         androidXExtension: AndroidXExtension
     ) {
         val kotlinMultiplatformAndroidComponentsExtension =
@@ -1093,8 +1095,7 @@
 
         buildToolsVersion = project.defaultAndroidConfig.buildToolsVersion
 
-        // b/366238650
-        defaultConfig.ndk.abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64"))
+        defaultConfig.ndk.abiFilters.addAll(SUPPORTED_BUILD_ABIS)
         defaultConfig.minSdk = defaultMinSdk
         defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
 
@@ -1155,8 +1156,7 @@
         )
     }
 
-    @Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
-    private fun DeprecatedKotlinMultiplatformAndroidTarget.configureAndroidBaseOptions(
+    private fun KotlinMultiplatformAndroidLibraryTarget.configureAndroidBaseOptions(
         project: Project,
         componentsExtension: KotlinMultiplatformAndroidComponentsExtension
     ) {
@@ -1170,13 +1170,13 @@
 
         lint.targetSdk = project.defaultAndroidConfig.targetSdk
         compilations
-            .withType(DeprecatedKotlinMultiplatformAndroidTestOnDeviceCompilation::class.java)
+            .withType(KotlinMultiplatformAndroidDeviceTestCompilation::class.java)
             .configureEach {
                 it.instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                 it.animationsDisabled = true
             }
         compilations
-            .withType(DeprecatedKotlinMultiplatformAndroidTestOnJvmCompilation::class.java)
+            .withType(KotlinMultiplatformAndroidHostTestCompilation::class.java)
             .configureEach {
                 it.isReturnDefaultValues = true
                 // Include resources in Robolectric tests as a workaround for b/184641296
@@ -1447,6 +1447,9 @@
 
         const val EXTENSION_NAME = "androidx"
 
+        // b/366238650
+        val SUPPORTED_BUILD_ABIS = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+
         /** Fail the build if a non-Studio task runs longer than expected */
         const val TASK_TIMEOUT_MINUTES = 60L
     }
@@ -1520,11 +1523,20 @@
  * [TASK_TIMEOUT_MINUTES].
  */
 internal fun Project.configureTaskTimeouts() {
+    // A set of tasks that sometimes take >60 minutes. b/383874664
+    val slowTasks =
+        setOf(
+            ":compose:ui:ui:compileReleaseAndroidTestKotlinAndroid",
+            ":compose:foundation:foundation:compileReleaseAndroidTestKotlinAndroid",
+            ":compose:foundation:foundation:integration-tests:lazy-tests:compileReleaseAndroidTestKotlin"
+        )
     tasks.configureEach { t ->
         // skip adding a timeout for some tasks that both take a long time and
         // that we can count on the user to monitor
         if (t !is StudioTask) {
-            t.timeout.set(Duration.ofMinutes(TASK_TIMEOUT_MINUTES))
+            t.timeout.set(
+                Duration.ofMinutes(if (t.path in slowTasks) 80L else TASK_TIMEOUT_MINUTES)
+            )
         }
     }
 }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt
index 97b9604..d5187fa 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt
@@ -14,10 +14,7 @@
  * limitations under the License.
  */
 
-@file:Suppress(
-    "UnstableApiUsage",
-    "TYPEALIAS_EXPANSION_DEPRECATION"
-) // // KotlinMultiplatformAndroidTarget / DeprecatedKotlinMultiplatformAndroidTarget
+@file:Suppress("UnstableApiUsage")
 
 package androidx.build
 
@@ -25,6 +22,7 @@
 import androidx.build.clang.MultiTargetNativeCompilation
 import androidx.build.clang.NativeLibraryBundler
 import androidx.build.clang.configureCinterop
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.gradle.api.KotlinMultiplatformAndroidPlugin
 import groovy.lang.Closure
 import java.io.File
@@ -50,7 +48,7 @@
 import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsTargetDsl
 import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinWasmTargetDsl
 import org.jetbrains.kotlin.gradle.targets.js.ir.DefaultIncrementalSyncTask
-import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension
+import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsEnvSpec
 import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
 import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport
 import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension
@@ -78,10 +76,10 @@
         // make sure to initialize the kotlin extension by accessing the property
         val extension = (kotlinExtension as ExtensionAware)
         project.plugins.apply(KotlinMultiplatformAndroidPlugin::class.java)
-        extension.extensions.getByType(DeprecatedKotlinMultiplatformAndroidTarget::class.java)
+        extension.extensions.getByType(KotlinMultiplatformAndroidLibraryTarget::class.java)
     }
 
-    val agpKmpExtension: DeprecatedKotlinMultiplatformAndroidTarget by agpKmpExtensionDelegate
+    val agpKmpExtension: KotlinMultiplatformAndroidLibraryTarget by agpKmpExtensionDelegate
 
     /**
      * The list of platforms that have been declared as supported in the build configuration.
@@ -420,8 +418,8 @@
 
     @JvmOverloads
     fun androidLibrary(
-        block: Action<DeprecatedKotlinMultiplatformAndroidTarget>? = null
-    ): DeprecatedKotlinMultiplatformAndroidTarget? {
+        block: Action<KotlinMultiplatformAndroidLibraryTarget>? = null
+    ): KotlinMultiplatformAndroidLibraryTarget? {
         supportedPlatforms.add(PlatformIdentifier.ANDROID)
         return if (project.enableJvm()) {
             agpKmpExtension.also { block?.execute(it) }
@@ -748,16 +746,18 @@
 }
 
 private fun Project.configureNode() {
-    rootProject.extensions.findByType<NodeJsRootExtension>()?.let { nodeJs ->
-        nodeJs.version = getVersionByName("node")
+    extensions.findByType<NodeJsEnvSpec>()?.let { nodeJs ->
+        nodeJs.version.set(getVersionByName("node"))
         if (!ProjectLayoutType.isPlayground(this)) {
-            nodeJs.downloadBaseUrl =
+            nodeJs.downloadBaseUrl.set(
                 File(project.getPrebuiltsRoot(), "androidx/external/org/nodejs/node")
                     .toURI()
                     .toString()
+            )
         }
     }
 
+    // https://youtrack.jetbrains.com/issue/KT-73913/K-Wasm-yarn-version-per-project
     rootProject.extensions.findByType(YarnRootExtension::class.java)?.let { yarn ->
         yarn.version = getVersionByName("yarn")
         yarn.yarnLockMismatchReport = YarnLockMismatchReport.FAIL
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
index 7521e49..c1a7e03 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXRootImplPlugin.kt
@@ -101,12 +101,12 @@
         extra.set("projects", ConcurrentHashMap<String, String>())
 
         /**
-         * Copy PrivacySandbox related APKs into [getTestConfigDirectory] before zipping. Flatten
-         * directory hierarchy as both TradeFed and FTL work with flat hierarchy.
+         * Copy App APKs (from ApkOutputProviders) into [getTestConfigDirectory] before zipping.
+         * Flatten directory hierarchy as both TradeFed and FTL work with flat hierarchy.
          */
         val finalizeConfigsTask =
             project.tasks.register(FINALIZE_TEST_CONFIGS_WITH_APKS_TASK, Copy::class.java) {
-                it.from(project.getPrivacySandboxFilesDirectory())
+                it.from(project.getAppApksFilesDirectory())
                 it.into(project.getTestConfigDirectory())
                 it.eachFile { f -> f.relativePath = RelativePath(true, f.name) }
                 it.includeEmptyDirs = false
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/DeprecatedKotlinMultiplatformAndroidTargetAndCompilation.kt b/buildSrc/private/src/main/kotlin/androidx/build/DeprecatedKotlinMultiplatformAndroidTargetAndCompilation.kt
deleted file mode 100644
index 700c36c..0000000
--- a/buildSrc/private/src/main/kotlin/androidx/build/DeprecatedKotlinMultiplatformAndroidTargetAndCompilation.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2024 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.
- */
-
-@file:Suppress("DEPRECATION", "UnstableApiUsage")
-
-package androidx.build
-
-import com.android.build.api.dsl.KotlinMultiplatformAndroidTarget
-import com.android.build.api.dsl.KotlinMultiplatformAndroidTestOnDeviceCompilation
-import com.android.build.api.dsl.KotlinMultiplatformAndroidTestOnJvmCompilation
-
-typealias DeprecatedKotlinMultiplatformAndroidTarget = KotlinMultiplatformAndroidTarget
-
-typealias DeprecatedKotlinMultiplatformAndroidTestOnDeviceCompilation =
-    KotlinMultiplatformAndroidTestOnDeviceCompilation
-
-typealias DeprecatedKotlinMultiplatformAndroidTestOnJvmCompilation =
-    KotlinMultiplatformAndroidTestOnJvmCompilation
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/LintConfiguration.kt b/buildSrc/private/src/main/kotlin/androidx/build/LintConfiguration.kt
index db4af57..d26533a 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/LintConfiguration.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/LintConfiguration.kt
@@ -15,6 +15,7 @@
  */
 package androidx.build
 
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.dsl.Lint
 import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
 import com.android.build.gradle.AppPlugin
@@ -79,9 +80,8 @@
         configureLint(extension.lint, isLibrary)
     }
 
-@Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
 private fun Project.configureAndroidMultiplatformProjectForLint(
-    extension: DeprecatedKotlinMultiplatformAndroidTarget,
+    extension: KotlinMultiplatformAndroidLibraryTarget,
     componentsExtension: KotlinMultiplatformAndroidComponentsExtension
 ) {
     componentsExtension.finalizeDsl {
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt b/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt
index 624b8af..6d244d1 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt
@@ -163,7 +163,10 @@
     }
 
     configure<PublishingExtension> {
-        repositories { it.maven { repo -> repo.setUrl(getRepositoryDirectory()) } }
+        repositories {
+            it.maven { repo -> repo.setUrl(getRepositoryDirectory()) }
+            it.maven { repo -> repo.setUrl(getPerProjectRepositoryDirectory()) }
+        }
         publications {
             if (appliesJavaGradlePluginPlugin()) {
                 // The 'java-gradle-plugin' will also add to the 'pluginMaven' publication
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/ProjectParser.kt b/buildSrc/private/src/main/kotlin/androidx/build/ProjectParser.kt
index 7a9a8ce..0cb1030 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/ProjectParser.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/ProjectParser.kt
@@ -92,7 +92,7 @@
 
 fun Project.parseBuildFile(buildFile: File): ProjectParser.ParsedProject {
     val parserProvider =
-        project.rootProject.gradle.sharedServices.registerIfAbsent(
+        project.gradle.sharedServices.registerIfAbsent(
             "ProjectParser",
             ProjectParser::class.java
         ) {}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/Release.kt b/buildSrc/private/src/main/kotlin/androidx/build/Release.kt
index 94707da..d3b8452 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/Release.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/Release.kt
@@ -15,102 +15,79 @@
  */
 package androidx.build
 
-import androidx.build.uptodatedness.cacheEvenIfNoOutputs
 import java.io.File
-import java.io.FileNotFoundException
-import org.gradle.api.Action
+import java.io.FileOutputStream
+import java.util.Calendar
+import java.util.GregorianCalendar
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
 import org.gradle.api.DefaultTask
 import org.gradle.api.GradleException
 import org.gradle.api.Project
-import org.gradle.api.file.DuplicatesStrategy
-import org.gradle.api.provider.Provider
-import org.gradle.api.tasks.Input
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputDirectory
 import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
-import org.gradle.api.tasks.bundling.Zip
-import org.gradle.plugin.devel.GradlePluginDevelopmentExtension
 import org.gradle.work.DisableCachingByDefault
 
-/** Simple description for an artifact that is released from this project. */
-data class Artifact(
-    @get:Input val mavenGroup: String,
-    @get:Input val projectName: String,
-    @get:Input val version: String
-) {
-    override fun toString() = "$mavenGroup:$projectName:$version"
-}
-
-/** Zip task that zips all artifacts from given candidates. */
+/** Zips all artifacts to publish. */
 @DisableCachingByDefault(because = "Zip tasks are not worth caching according to Gradle")
-// See
-// https://github.com/gradle/gradle/commit/7e5c5bc9b2c23d872e1c45c855f07ca223f6c270#diff-ce55b0f0cdcf2174eb47d333d348ff6fbd9dbe5cd8c3beeeaf633ea23b74ed9eR38
-open class GMavenZipTask : Zip() {
+abstract class GMavenZipTask : DefaultTask() {
 
-    init {
-        // multiple artifacts in the same group might have the same maven-metadata.xml
-        duplicatesStrategy = DuplicatesStrategy.EXCLUDE
-    }
-
-    /** Set to true to include maven-metadata.xml */
-    @get:Input var includeMetadata: Boolean = false
+    /** Whether this build adds automatic constraints between projects in the same group */
+    @Internal val shouldAddGroupConstraints = project.shouldAddGroupConstraints()
 
     /** Repository containing artifacts to include */
-    @get:Internal lateinit var androidxRepoOut: File
+    @get:InputDirectory
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    abstract val projectRepositoryDir: DirectoryProperty
 
-    fun addCandidate(artifact: Artifact) {
-        val groupSubdir = artifact.mavenGroup.replace('.', '/')
-        val projectSubdir = File("$groupSubdir/${artifact.projectName}")
-        val artifactSubdir = File("$projectSubdir/${artifact.version}")
-        // We specifically pass the subdirectory and specific files into 'from' so that Gradle
-        // knows that other directories aren't related:
-        // 1. changes in other directories shouldn't cause this task to become out of date
-        // 2. contents of other directories shouldn't be cached:
-        //    https://github.com/gradle/gradle/issues/24368
-        from("$androidxRepoOut/$artifactSubdir") { spec ->
-            spec.into("m2repository/$artifactSubdir")
+    /** Zip file to save artifacts to */
+    @get:OutputFile abstract val archiveFile: RegularFileProperty
+
+    @TaskAction
+    fun createZip() {
+        if (!shouldAddGroupConstraints.get() && !isSnapshotBuild()) {
+            throw GradleException(
+                """
+                Cannot publish artifacts without setting -P$ADD_GROUP_CONSTRAINTS=true
+
+                This property is required when building artifacts to publish
+
+                (but this property can reduce remote cache usage so it is disabled by default)
+
+                See AndroidXGradleProperties.kt for more information about this property
+                """
+                    .trimIndent()
+            )
         }
-        if (includeMetadata) {
-            val suffixes = setOf("", ".md5", ".sha1", ".sha256", ".sha512")
-            for (suffix in suffixes) {
-                val filename = "maven-metadata.xml$suffix"
-                from("$androidxRepoOut/$projectSubdir/$filename") { spec ->
-                    spec.into("m2repository/$projectSubdir")
+        val sourceDir = projectRepositoryDir.get().asFile
+        ZipOutputStream(FileOutputStream(archiveFile.get().asFile)).use { zipOut ->
+            zipOut.putNextEntry(
+                // Top-level of the ZIP to align with Maven's expected repository structure
+                ZipEntry("m2repository/").also { it.time = CONSTANT_TIME_FOR_ZIP_ENTRIES }
+            )
+            zipOut.closeEntry()
+
+            sourceDir.walkTopDown().forEach { fileOrDir ->
+                if (fileOrDir == sourceDir) return@forEach
+
+                val relativePath = fileOrDir.relativeTo(sourceDir).invariantSeparatorsPath
+                val entryName =
+                    "m2repository/$relativePath" + if (fileOrDir.isDirectory) "/" else ""
+
+                zipOut.putNextEntry(
+                    ZipEntry(entryName).also { it.time = CONSTANT_TIME_FOR_ZIP_ENTRIES }
+                )
+                if (fileOrDir.isFile) {
+                    fileOrDir.inputStream().use { it.copyTo(zipOut) }
                 }
-            }
-        }
-    }
-
-    /** Config action that configures the task when necessary. */
-    class ConfigAction(private val params: Params) : Action<GMavenZipTask> {
-        data class Params(
-            /** Maven group for the task. "" if multiple groups or only one project */
-            val mavenGroup: String,
-            /** Set to true to include maven-metadata.xml */
-            var includeMetadata: Boolean,
-            /** The root of the repository where built libraries can be found */
-            val androidxRepoOut: File,
-            /** The out folder where the zip will be created */
-            val distDir: File,
-            /** Prefix of file name to create */
-            val fileNamePrefix: String,
-            /** The build number specified by the server */
-            val buildNumber: String
-        )
-
-        override fun execute(task: GMavenZipTask) {
-            params.apply {
-                task.description =
-                    """
-                    Creates a maven repository that includes just the libraries compiled in
-                    this project.
-                    Group: ${if (mavenGroup != "") mavenGroup else "All"}
-                    """
-                        .trimIndent()
-                task.androidxRepoOut = androidxRepoOut
-                task.destinationDirectory.set(distDir)
-                task.includeMetadata = params.includeMetadata
-                task.archiveBaseName.set(getZipName(fileNamePrefix, mavenGroup))
+                zipOut.closeEntry()
             }
         }
     }
@@ -126,9 +103,6 @@
     const val PROJECT_ZIPS_FOLDER = "per-project-zips"
     private const val GLOBAL_ZIP_PREFIX = "top-of-tree-m2repository"
 
-    // lazily created config action params so that we don't keep re-creating them
-    private var configActionParams: GMavenZipTask.ConfigAction.Params? = null
-
     /**
      * Registers the project to be included in its group's zip file as well as the global zip files.
      */
@@ -159,7 +133,6 @@
                 "Cannot register a project to release if it does not have a mavenVersion set up"
             )
         }
-        val version = project.version
 
         val projectZipTask =
             getProjectZipTask(project, androidXExtension.isIsolatedProjectsEnabled())
@@ -169,80 +142,8 @@
                 getGlobalFullZipTask(project, androidXExtension.isIsolatedProjectsEnabled())
             )
 
-        val artifacts = androidXExtension.publishedArtifacts
         val publishTask = project.tasks.named("publish")
-        zipTasks.forEach {
-            it.configure { zipTask ->
-                artifacts.forEach { artifact -> zipTask.addCandidate(artifact) }
-
-                // Add additional artifacts needed for Gradle Plugins
-                if (androidXExtension.type == LibraryType.GRADLE_PLUGIN) {
-                    project.extensions
-                        .getByType(GradlePluginDevelopmentExtension::class.java)
-                        .plugins
-                        .forEach { plugin ->
-                            zipTask.addCandidate(
-                                Artifact(
-                                    mavenGroup = plugin.id,
-                                    projectName = "${plugin.id}.gradle.plugin",
-                                    version = version.toString()
-                                )
-                            )
-                        }
-                }
-
-                zipTask.dependsOn(publishTask)
-            }
-        }
-
-        val verifyInputs = getVerifyProjectZipInputsTask(project)
-        verifyInputs.configure { verifyTask ->
-            verifyTask.dependsOn(publishTask)
-            artifacts.forEach { artifact -> verifyTask.addCandidate(artifact) }
-        }
-        val verifyOutputs = getVerifyProjectZipOutputsTask(project)
-        verifyOutputs.configure { verifyTask ->
-            verifyTask.dependsOn(projectZipTask)
-            artifacts.forEach { artifact -> verifyTask.addCandidate(artifact) }
-        }
-        projectZipTask.configure { zipTask ->
-            zipTask.dependsOn(verifyInputs)
-            zipTask.finalizedBy(verifyOutputs)
-            val verifyOutputsTask = verifyOutputs.get()
-            verifyOutputsTask.addFile(zipTask.archiveFile.get().asFile)
-        }
-    }
-
-    /**
-     * Create config action parameters for the project and group. If group is `null`, parameters are
-     * created for the global tasks.
-     */
-    private fun getParams(
-        project: Project,
-        distDir: File,
-        fileNamePrefix: String = "",
-        group: String? = null
-    ): GMavenZipTask.ConfigAction.Params {
-        // Make base params or reuse if already created
-        val params =
-            configActionParams
-                ?: GMavenZipTask.ConfigAction.Params(
-                        mavenGroup = "",
-                        includeMetadata = false,
-                        androidxRepoOut = project.getRepositoryDirectory(),
-                        distDir = distDir,
-                        fileNamePrefix = fileNamePrefix,
-                        buildNumber = getBuildId()
-                    )
-                    .also { configActionParams = it }
-        distDir.mkdirs()
-
-        // Copy base params and apply any specific differences
-        return params.copy(
-            mavenGroup = group ?: "",
-            distDir = distDir,
-            fileNamePrefix = fileNamePrefix
-        )
+        zipTasks.forEach { it.configure { zipTask -> zipTask.dependsOn(publishTask) } }
     }
 
     /** Registers an archive task as a dependency of the anchor task */
@@ -272,16 +173,11 @@
         if (projectIsolationEnabled) return null
         return project.rootProject.maybeRegister(
             name = FULL_ARCHIVE_TASK_NAME,
-            onConfigure = {
-                GMavenZipTask.ConfigAction(
-                        getParams(
-                                project,
-                                project.getDistributionDirectory(),
-                                fileNamePrefix = GLOBAL_ZIP_PREFIX
-                            )
-                            .copy(includeMetadata = true)
-                    )
-                    .execute(it)
+            onConfigure = { task: GMavenZipTask ->
+                task.archiveFile.set(
+                    File(project.getDistributionDirectory(), "${getZipName(GLOBAL_ZIP_PREFIX)}.zip")
+                )
+                task.projectRepositoryDir.set(project.getRepositoryDirectory())
             },
             onRegister = { taskProvider: TaskProvider<GMavenZipTask> ->
                 project.addToAnchorTask(taskProvider)
@@ -295,183 +191,35 @@
     ): TaskProvider<GMavenZipTask> {
         val taskProvider =
             project.tasks.register(PROJECT_ARCHIVE_ZIP_TASK_NAME, GMavenZipTask::class.java) {
-                task: GMavenZipTask ->
-                GMavenZipTask.ConfigAction(
-                        getParams(
-                                project = project,
-                                distDir =
-                                    File(project.getDistributionDirectory(), PROJECT_ZIPS_FOLDER),
-                                fileNamePrefix = project.projectZipPrefix()
-                            )
-                            .copy(includeMetadata = true)
-                    )
-                    .execute(task)
+                it.archiveFile.set(
+                    File(project.getDistributionDirectory(), project.getProjectZipPath())
+                )
+                it.projectRepositoryDir.set(project.getPerProjectRepositoryDirectory())
             }
         if (!projectIsolationEnabled) project.addToAnchorTask(taskProvider)
         return taskProvider
     }
-
-    private fun getVerifyProjectZipInputsTask(project: Project): TaskProvider<VerifyGMavenZipTask> {
-        return project.tasks.register(
-            "verifyInputs$PROJECT_ARCHIVE_ZIP_TASK_NAME",
-            VerifyGMavenZipTask::class.java
-        )
-    }
-
-    private fun getVerifyProjectZipOutputsTask(
-        project: Project
-    ): TaskProvider<VerifyGMavenZipTask> {
-        return project.tasks.register(
-            "verifyOutputs$PROJECT_ARCHIVE_ZIP_TASK_NAME",
-            VerifyGMavenZipTask::class.java
-        )
-    }
 }
 
-// b/273294710
-@DisableCachingByDefault(
-    because = "This task only checks the existence of files and isn't worth caching"
-)
-open class VerifyGMavenZipTask : DefaultTask() {
-    @Input val filesToVerify = mutableListOf<File>()
-
-    /** Whether this build adds automatic constraints between projects in the same group */
-    @get:Input val shouldAddGroupConstraints: Provider<Boolean>
-
-    init {
-        cacheEvenIfNoOutputs()
-        shouldAddGroupConstraints = project.shouldAddGroupConstraints()
-    }
-
-    fun addFile(file: File) {
-        filesToVerify.add(file)
-    }
-
-    fun addCandidate(artifact: Artifact) {
-        val groupSubdir = artifact.mavenGroup.replace('.', '/')
-        val projectSubdir = File("$groupSubdir/${artifact.projectName}")
-        val androidxRepoOut = project.getRepositoryDirectory()
-        val fromDir = project.file("$androidxRepoOut/$projectSubdir")
-        addFile(File(fromDir, artifact.version))
-    }
-
-    @TaskAction
-    fun execute() {
-        verifySettings()
-        verifyFiles()
-    }
-
-    private fun verifySettings() {
-        if (!shouldAddGroupConstraints.get() && !isSnapshotBuild()) {
-            throw GradleException(
-                """
-                Cannot publish artifacts without setting -P$ADD_GROUP_CONSTRAINTS=true
-
-                This property is required when building artifacts to publish
-
-                (but this property can reduce remote cache usage so it is disabled by default)
-
-                See AndroidXGradleProperties.kt for more information about this property
-                """
-                    .trimIndent()
-            )
-        }
-    }
-
-    private fun verifyFiles() {
-        val missingFiles = mutableListOf<String>()
-        val emptyDirs = mutableListOf<String>()
-        filesToVerify.forEach { file ->
-            if (!file.exists()) {
-                missingFiles.add(file.path)
-            } else {
-                if (file.isDirectory) {
-                    if (file.listFiles().isEmpty()) {
-                        emptyDirs.add(file.path)
-                    }
-                }
-            }
-        }
-
-        if (missingFiles.isNotEmpty() || emptyDirs.isNotEmpty()) {
-            val checkedFilesString = filesToVerify.toString()
-            val missingFileString = missingFiles.toString()
-            val emptyDirsString = emptyDirs.toString()
-            throw FileNotFoundException(
-                "GMavenZip ${missingFiles.size} missing files: $missingFileString, " +
-                    "${emptyDirs.size} empty dirs: $emptyDirsString. " +
-                    "Checked files: $checkedFilesString"
-            )
-        }
-    }
-}
-
-val AndroidXExtension.publishedArtifacts: List<Artifact>
-    get() {
-        val groupString = mavenGroup?.group!!
-        val versionString = project.version.toString()
-        val artifacts =
-            mutableListOf(
-                Artifact(
-                    mavenGroup = groupString,
-                    projectName = project.name,
-                    version = versionString
-                )
-            )
-
-        // Add platform-specific artifacts, if necessary.
-        artifacts +=
-            publishPlatforms.map { suffix ->
-                Artifact(
-                    mavenGroup = groupString,
-                    projectName = "${project.name}-$suffix",
-                    version = versionString
-                )
-            }
-
-        return artifacts
-    }
-
-private val AndroidXExtension.publishPlatforms: List<String>
-    get() {
-        val potentialTargets =
-            project.multiplatformExtension
-                ?.targets
-                ?.asMap
-                ?.filterValues { it.publishable }
-                ?.keys
-                ?.map {
-                    it.lowercase()
-                        // Remove when https://youtrack.jetbrains.com/issue/KT-70072 is fixed.
-                        // MultiplatformExtension.targets includes `wasmjs` in its list, however,
-                        // the publication folder for this target is named `wasm-js`. Not having
-                        // this replace causes the verifyInputscreateProjectZip task to fail
-                        // as it is looking for a file named wasmjs
-                        .replace("wasmjs", "wasm-js")
-                } ?: emptySet()
-        val declaredTargets = potentialTargets.filter { it != "metadata" }
-        return declaredTargets.toList()
-    }
-
 private fun Project.projectZipPrefix(): String {
     return "${project.group}-${project.name}"
 }
 
-private fun getZipName(fileNamePrefix: String, mavenGroup: String): String {
-    val fileSuffix =
-        if (mavenGroup == "") {
-            "all"
-        } else {
-            mavenGroup.split(".").joinToString("-")
-        } + "-${getBuildId()}"
-    return "$fileNamePrefix-$fileSuffix"
-}
+private fun getZipName(fileNamePrefix: String) = "$fileNamePrefix-all-${getBuildId()}"
 
 fun Project.getProjectZipPath(): String {
     return Release.PROJECT_ZIPS_FOLDER +
         "/" +
         // We pass in a "" because that mimics not passing the group to getParams() inside
         // the getProjectZipTask function
-        getZipName(projectZipPrefix(), "") +
+        getZipName(projectZipPrefix()) +
         "-${project.version}.zip"
 }
+
+/**
+ * Strip timestamps from the zip entries to generate consistent output. Set to be ths same as what
+ * Gradle uses:
+ * https://github.com/gradle/gradle/blob/master/platforms/core-runtime/files/src/main/java/org/gradle/api/internal/file/archive/ZipEntryConstants.java
+ */
+private val CONSTANT_TIME_FOR_ZIP_ENTRIES =
+    GregorianCalendar(1980, Calendar.FEBRUARY, 1, 0, 0, 0).timeInMillis
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/docs/AndroidXDocsImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/docs/AndroidXDocsImplPlugin.kt
index d39c11b..4290ac9 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/docs/AndroidXDocsImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/docs/AndroidXDocsImplPlugin.kt
@@ -252,6 +252,8 @@
             // during the task execution, as that breaks configuration caching.
             val localVar = archiveOperations
             val tempDir = project.layout.buildDirectory.dir("tmp/JvmSources").get().asFile
+            // Get rid of stale files in the directory
+            tempDir.deleteRecursively()
             task.into(sourcesDestinationDirectory)
             task.from(
                 pairProvider
@@ -275,6 +277,8 @@
                 // during the task execution, as that breaks configuration caching.
                 val localVar = archiveOperations
                 val tempDir = project.layout.buildDirectory.dir("tmp/SampleSources").get().asFile
+                // Get rid of stale files in the directory
+                tempDir.deleteRecursively()
                 task.into(samplesDestinationDirectory)
                 task.from(
                     pairProvider
@@ -852,6 +856,9 @@
             projectLayout.buildDirectory.dir("tmp/MultiplatformSources").get().asFile
         val tempSamplesDir =
             projectLayout.buildDirectory.dir("tmp/MultiplatformSamples").get().asFile
+        // Get rid of stale files in the directories
+        tempSourcesDir.deleteRecursively()
+        tempSamplesDir.deleteRecursively()
 
         val (sources, samples) =
             inputJars
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/java/JavaCompileInputs.kt b/buildSrc/private/src/main/kotlin/androidx/build/java/JavaCompileInputs.kt
index 706eaf9..cb3d5ae 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/java/JavaCompileInputs.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/java/JavaCompileInputs.kt
@@ -13,13 +13,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@file:Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
 
 package androidx.build.java
 
-import androidx.build.DeprecatedKotlinMultiplatformAndroidTarget
 import androidx.build.getAndroidJar
 import androidx.build.multiplatformExtension
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.variant.LibraryAndroidComponentsExtension
 import com.android.build.api.variant.LibraryVariant
 import org.gradle.api.Project
@@ -134,7 +133,7 @@
                 }
             val target =
                 kmpExtension.targets
-                    .withType(DeprecatedKotlinMultiplatformAndroidTarget::class.java)
+                    .withType(KotlinMultiplatformAndroidLibraryTarget::class.java)
                     .single()
             val compilation = target.findCompilation(KotlinCompilation.MAIN_COMPILATION_NAME)
             val sourceCollection = project.sourceFiles(compilation)
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/resources/PublicResourcesStubHelper.kt b/buildSrc/private/src/main/kotlin/androidx/build/resources/PublicResourcesStubHelper.kt
index f8a7cb2..bcf9c37 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/resources/PublicResourcesStubHelper.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/resources/PublicResourcesStubHelper.kt
@@ -13,12 +13,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@file:Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
 
 package androidx.build.resources
 
-import androidx.build.DeprecatedKotlinMultiplatformAndroidTarget
 import androidx.build.getSupportRootFolder
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.variant.LibraryVariant
 import java.io.File
 import org.gradle.api.Project
@@ -48,7 +47,7 @@
         }
     val sourceSet =
         kmpExtension.targets
-            .withType(DeprecatedKotlinMultiplatformAndroidTarget::class.java)
+            .withType(KotlinMultiplatformAndroidLibraryTarget::class.java)
             .single()
             .compilations
             .getByName(KotlinCompilation.MAIN_COMPILATION_NAME)
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt b/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
index 1bdf993..46396f1 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
@@ -18,7 +18,6 @@
 
 import androidx.build.AndroidXPlaygroundRootImplPlugin
 import androidx.build.BundleInsideHelper
-import androidx.build.GMavenZipTask
 import androidx.build.ProjectLayoutType
 import androidx.build.addToBuildOnServer
 import androidx.build.getDistributionDirectory
@@ -110,10 +109,6 @@
         // klib zip tasks don't generally embed other dependencies in them
         return listOf()
     }
-    if (task is GMavenZipTask) {
-        // A GMavenZipTask just zips one or more artifacts we've already built
-        return listOf()
-    }
 
     val projectPath = path
     val taskName = task.name
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/sources/SourceJarTaskHelper.kt b/buildSrc/private/src/main/kotlin/androidx/build/sources/SourceJarTaskHelper.kt
index e87ad9e..40f7e20 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/sources/SourceJarTaskHelper.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/sources/SourceJarTaskHelper.kt
@@ -13,11 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@file:Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
 
 package androidx.build.sources
 
-import androidx.build.DeprecatedKotlinMultiplatformAndroidTarget
 import androidx.build.LazyInputsCopyTask
 import androidx.build.capitalize
 import androidx.build.dackka.DokkaAnalysisPlatform
@@ -25,6 +23,7 @@
 import androidx.build.hasAndroidMultiplatformPlugin
 import androidx.build.multiplatformExtension
 import androidx.build.registerAsComponentForPublishing
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.variant.LibraryAndroidComponentsExtension
 import com.android.build.api.variant.LibraryVariant
 import com.google.gson.GsonBuilder
@@ -92,7 +91,7 @@
 
 fun Project.configureMultiplatformSourcesForAndroid(
     variantName: String,
-    target: DeprecatedKotlinMultiplatformAndroidTarget,
+    target: KotlinMultiplatformAndroidLibraryTarget,
     samplesProjects: MutableCollection<Project>
 ) {
     val sourceJar =
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
index 5220b03b..1c9532b 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AndroidTestConfigBuilder.kt
@@ -21,13 +21,11 @@
 
 class ConfigBuilder {
     lateinit var configName: String
-    var appApkName: String? = null
-    var appApkSha256: String? = null
-    val appSplits = mutableListOf<String>()
+    lateinit var configType: TestConfigType
+    var appApksModel: AppApksModel? = null
     lateinit var applicationId: String
     var isMicrobenchmark: Boolean = false
     var isMacrobenchmark: Boolean = false
-    var enablePrivacySandbox: Boolean = false
     var isPostsubmit: Boolean = true
     lateinit var minSdk: String
     val tags = mutableListOf<String>()
@@ -35,16 +33,13 @@
     lateinit var testApkSha256: String
     lateinit var testRunner: String
     val additionalApkKeys = mutableListOf<String>()
-    val initialSetupApks = mutableListOf<String>()
     val instrumentationArgsMap = mutableMapOf<String, String>()
 
     fun configName(configName: String) = apply { this.configName = configName }
 
-    fun appApkName(appApkName: String) = apply { this.appApkName = appApkName }
+    fun configType(configType: TestConfigType) = apply { this.configType = configType }
 
-    fun appApkSha256(appApkSha256: String) = apply { this.appApkSha256 = appApkSha256 }
-
-    fun appSplits(appSplits: List<String>) = apply { this.appSplits.addAll(appSplits) }
+    fun appApksModel(appApksModel: AppApksModel) = apply { this.appApksModel = appApksModel }
 
     fun applicationId(applicationId: String) = apply { this.applicationId = applicationId }
 
@@ -58,18 +53,12 @@
 
     fun isPostsubmit(isPostsubmit: Boolean) = apply { this.isPostsubmit = isPostsubmit }
 
-    fun enablePrivacySandbox(enablePrivacySandbox: Boolean) = apply {
-        this.enablePrivacySandbox = enablePrivacySandbox
-    }
-
     fun minSdk(minSdk: String) = apply { this.minSdk = minSdk }
 
     fun tag(tag: String) = apply { this.tags.add(tag) }
 
     fun additionalApkKeys(keys: List<String>) = apply { additionalApkKeys.addAll(keys) }
 
-    fun initialSetupApks(apks: List<String>) = apply { initialSetupApks.addAll(apks) }
-
     fun testApkName(testApkName: String) = apply { this.testApkName = testApkName }
 
     fun testApkSha256(testApkSha256: String) = apply { this.testApkSha256 = testApkSha256 }
@@ -92,6 +81,12 @@
                 listOf(InstrumentationArg("notAnnotation", "androidx.test.filters.FlakyTest"))
             }
         )
+        if (configType.isAddedToInstrumentationArgs()) {
+            instrumentationArgsList.add(
+                InstrumentationArg("androidx.testConfigType", configType.toString())
+            )
+        }
+        val appApk = singleAppApk()
         val values =
             mapOf(
                 "name" to configName,
@@ -99,8 +94,8 @@
                 "testSuiteTags" to tags,
                 "testApk" to testApkName,
                 "testApkSha256" to testApkSha256,
-                "appApk" to appApkName,
-                "appApkSha256" to appApkSha256,
+                "appApk" to appApk?.name,
+                "appApkSha256" to appApk?.sha256,
                 "instrumentationArgs" to instrumentationArgsList,
                 "additionalApkKeys" to additionalApkKeys
             )
@@ -132,7 +127,7 @@
                     listOf(
                         InstrumentationArg(
                             "androidx.benchmark.output.payload.appApkSha256",
-                            checkNotNull(appApkSha256) {
+                            checkNotNull(appApksModel?.sha256()) {
                                 "app apk sha should be provided for macrobenchmarks."
                             }
                         ),
@@ -142,6 +137,11 @@
                 )
             }
         }
+        if (configType.isAddedToInstrumentationArgs()) {
+            instrumentationArgsList.add(
+                InstrumentationArg("androidx.testConfigType", configType.toString())
+            )
+        }
         instrumentationArgsList.forEach { (key, value) ->
             sb.append(
                 """
@@ -152,14 +152,13 @@
             )
         }
         sb.append(SETUP_INCLUDE).append(TARGET_PREPARER_OPEN.replace("CLEANUP_APKS", "true"))
-        initialSetupApks.forEach { apk -> sb.append(APK_INSTALL_OPTION.replace("APK_NAME", apk)) }
         sb.append(APK_INSTALL_OPTION.replace("APK_NAME", testApkName))
-        if (!appApkName.isNullOrEmpty()) {
-            if (appSplits.isEmpty()) {
-                sb.append(APK_INSTALL_OPTION.replace("APK_NAME", appApkName!!))
-            } else {
-                val apkList = appApkName + "," + appSplits.joinToString(",")
+        appApksModel?.apkGroups?.forEach { group ->
+            if (group.isUsingApkSplits()) {
+                val apkList = group.apks.map(ApkFile::name).joinToString(",")
                 sb.append(APK_WITH_SPLITS_INSTALL_OPTION.replace("APK_LIST", apkList))
+            } else {
+                sb.append(APK_INSTALL_OPTION.replace("APK_NAME", group.apks.single().name))
             }
         }
         sb.append(TARGET_PREPARER_CLOSE)
@@ -167,7 +166,7 @@
         if (isMicrobenchmark) {
             sb.append(benchmarkPostInstallCommandOption(applicationId))
         }
-        if (enablePrivacySandbox) {
+        if (configType == TestConfigType.PRIVACY_SANDBOX_MAIN) {
             sb.append(PRIVACY_SANDBOX_ENABLE_PREPARER)
         }
         sb.append(TEST_BLOCK_OPEN)
@@ -187,6 +186,14 @@
         sb.append(CONFIGURATION_CLOSE)
         return sb.toString()
     }
+
+    private fun singleAppApk(): ApkFile? {
+        val apkGroups = appApksModel?.apkGroups
+        if (apkGroups.isNullOrEmpty()) {
+            return null
+        }
+        return apkGroups.single().apks.single()
+    }
 }
 
 private fun mediaInstrumentationArgsForJson(
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksModel.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksModel.kt
new file mode 100644
index 0000000..52867c9
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksModel.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+import com.google.common.hash.Hashing
+import com.google.common.io.BaseEncoding
+import com.google.gson.GsonBuilder
+
+/**
+ * List of App APKs required to install the app.
+ *
+ * APKs must be installed group by group in same order as listed.
+ */
+data class AppApksModel(val apkGroups: List<ApkFileGroup>) {
+
+    @Suppress("UnstableApiUsage") // guava Hashing is marked as @Beta
+    fun sha256(): String? {
+        val shaHashes = apkGroups.flatMap(ApkFileGroup::apks).map(ApkFile::sha256)
+        if (shaHashes.isEmpty()) {
+            return null
+        }
+
+        if (shaHashes.size == 1) {
+            return shaHashes[0]
+        }
+
+        val hasher = Hashing.sha256().newHasher()
+        shaHashes.forEach { hasher.putString(it, Charsets.UTF_8) }
+        return BaseEncoding.base16().lowerCase().encode(hasher.hash().asBytes())
+    }
+
+    fun toJson(): String = GsonBuilder().setPrettyPrinting().create().toJson(this)
+
+    companion object {
+        fun fromJson(json: String): AppApksModel =
+            GsonBuilder().create().fromJson(json, AppApksModel::class.java)
+    }
+}
+
+/** Group of APKs / Splits that needs to be installed together. */
+data class ApkFileGroup(val apks: List<ApkFile>) {
+    fun isUsingApkSplits(): Boolean = apks.size > 1
+}
+
+/** Single APK / Split file */
+data class ApkFile(val name: String, val sha256: String = "")
+
+internal fun singleFileAppApksModel(name: String, sha256: String = ""): AppApksModel =
+    AppApksModel(apkGroups = listOf(singleFileApkFileGroup(name, sha256)))
+
+internal fun singleFileApkFileGroup(name: String, sha256: String = ""): ApkFileGroup =
+    ApkFileGroup(apks = listOf(ApkFile(name, sha256)))
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksTestConfigurationHelper.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksTestConfigurationHelper.kt
new file mode 100644
index 0000000..7060aae
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/AppApksTestConfigurationHelper.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+import androidx.build.AndroidXImplPlugin.Companion.SUPPORTED_BUILD_ABIS
+import androidx.build.androidXExtension
+import androidx.build.asFilenamePrefix
+import androidx.build.dependencyTracker.AffectedModuleDetector
+import androidx.build.getAppApksFilesDirectory
+import com.android.build.api.variant.ApkOutputProviders
+import com.android.build.api.variant.DeviceSpec
+import com.android.build.api.variant.Variant
+import java.util.function.Consumer
+import kotlin.math.max
+import org.gradle.api.Project
+import org.gradle.api.file.RegularFile
+import org.gradle.api.provider.Provider
+
+@Suppress("UnstableApiUsage") // ApkOutputProviders
+internal fun Project.registerCopyPrivacySandboxMainAppApksTask(
+    variant: Variant,
+    outputProviders: ApkOutputProviders,
+    excludeTestApk: Provider<RegularFile>
+): Provider<CopyApksFromOutputProviderTask> =
+    registerCopyApksFromOutputProviderTask(
+        taskName = "CopyPrivacySandboxMainAppApks${variant.name}",
+        variant,
+        outputProviders,
+        excludeTestApk,
+        outputFileNamePrefix = "${path.asFilenamePrefix()}-${variant.name}-sandbox-enabled",
+        outputApkModelFileName = "PrivacySandboxMainAppApksModel${variant.name}.json",
+        deviceSpec = mainSandboxDeviceSpec(variant.minSdk.apiLevel)
+    )
+
+@Suppress("UnstableApiUsage") // ApkOutputProviders
+internal fun Project.registerCopyPrivacySandboxCompatAppApksTask(
+    variant: Variant,
+    outputProviders: ApkOutputProviders,
+    excludeTestApk: Provider<RegularFile>
+): Provider<CopyApksFromOutputProviderTask> =
+    registerCopyApksFromOutputProviderTask(
+        taskName = "CopyPrivacySandboxCompatAppApks${variant.name}",
+        variant,
+        outputProviders,
+        excludeTestApk,
+        outputFileNamePrefix = "${path.asFilenamePrefix()}-${variant.name}-sandbox-compat",
+        outputApkModelFileName = "PrivacySandboxCompatAppApksModel${variant.name}.json",
+        deviceSpec = compatSandboxDeviceSpec(variant.minSdk.apiLevel)
+    )
+
+internal fun Project.registerCopyAppApkFromArtifactsTask(
+    variant: Variant,
+    configureAction: Consumer<CopyApkFromArtifactsTask> // For lazy task init
+): Provider<CopyApkFromArtifactsTask> =
+    tasks.register("CopyAppApk${variant.name}", CopyApkFromArtifactsTask::class.java) { task ->
+        task.appLoader.set(variant.artifacts.getBuiltArtifactsLoader())
+
+        configureAction.accept(task)
+
+        task.outputAppApksModel.set(layout.buildDirectory.file("AppApksModel${variant.name}.json"))
+
+        task.androidTestSourceCode.from(getTestSourceSetsForAndroid(variant))
+        task.enabled = androidXExtension.deviceTests.enabled
+        AffectedModuleDetector.configureTaskGuard(task)
+    }
+
+@Suppress("UnstableApiUsage") // ApkOutputProviders
+private fun Project.registerCopyApksFromOutputProviderTask(
+    taskName: String,
+    variant: Variant,
+    outputProviders: ApkOutputProviders,
+    excludeTestApk: Provider<RegularFile>,
+    outputFileNamePrefix: String,
+    outputApkModelFileName: String,
+    deviceSpec: DeviceSpec
+): Provider<CopyApksFromOutputProviderTask> {
+    val copyApksTask =
+        tasks.register(taskName, CopyApksFromOutputProviderTask::class.java) { task ->
+            task.excludeTestApk.set(excludeTestApk)
+
+            task.outputFilenamesPrefix.set(outputFileNamePrefix)
+            task.outputDirectory.set(
+                getAppApksFilesDirectory().map { it.dir("${path.asFilenamePrefix()}-$taskName") }
+            )
+
+            task.outputAppApksModel.set(layout.buildDirectory.file(outputApkModelFileName))
+
+            task.androidTestSourceCode.from(getTestSourceSetsForAndroid(variant))
+            task.enabled = androidXExtension.deviceTests.enabled
+            AffectedModuleDetector.configureTaskGuard(task)
+        }
+
+    outputProviders.provideApkOutputToTask(
+        copyApksTask,
+        CopyApksFromOutputProviderTask::apkOutput,
+        deviceSpec
+    )
+
+    return copyApksTask
+}
+
+@Suppress("UnstableApiUsage") // DeviceSpec
+private fun mainSandboxDeviceSpec(minApiLevel: Int): DeviceSpec =
+    DeviceSpec.Builder()
+        .setApiLevel(max(minApiLevel, PRIVACY_SANDBOX_MIN_API_LEVEL))
+        .setSupportsPrivacySandbox(true)
+        .setAbis(SUPPORTED_BUILD_ABIS) // To pass filters in defaultConfig.ndk.abiFilters
+        .build()
+
+@Suppress("UnstableApiUsage") // DeviceSpec
+private fun compatSandboxDeviceSpec(minApiLevel: Int): DeviceSpec =
+    DeviceSpec.Builder()
+        .setApiLevel(minApiLevel)
+        .setSupportsPrivacySandbox(false)
+        .setAbis(SUPPORTED_BUILD_ABIS) // To pass filters in defaultConfig.ndk.abiFilters
+        .build()
+
+internal const val PRIVACY_SANDBOX_MIN_API_LEVEL = 34
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApkFromArtifactsTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApkFromArtifactsTask.kt
new file mode 100644
index 0000000..f707929
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApkFromArtifactsTask.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+import com.android.build.api.variant.BuiltArtifactsLoader
+import java.io.File
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/** Copy single APK (from AGP artifacts) needed for building androidTest.zip */
+@DisableCachingByDefault(because = "Only filesystem operations")
+abstract class CopyApkFromArtifactsTask @Inject constructor(private val objects: ObjectFactory) :
+    DefaultTask() {
+
+    /** File existence check to determine whether to run this task. */
+    @get:InputFiles
+    @get:SkipWhenEmpty
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val androidTestSourceCode: ConfigurableFileCollection
+
+    @get:InputFiles
+    @get:Optional
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val appFolder: DirectoryProperty
+
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val appFileCollection: ConfigurableFileCollection
+
+    @get:Internal abstract val appLoader: Property<BuiltArtifactsLoader>
+
+    @get:OutputFile abstract val outputAppApk: RegularFileProperty
+
+    @get:OutputFile abstract val outputAppApksModel: RegularFileProperty
+
+    @TaskAction
+    fun createApks() {
+        // Decides where to load the app apk from, depending on whether appFolder or
+        // appFileCollection has been set.
+        val appDir =
+            if (appFolder.isPresent && appFileCollection.files.isEmpty()) {
+                appFolder.get()
+            } else if (!appFolder.isPresent && appFileCollection.files.size == 1) {
+                objects.directoryProperty().also { it.set(appFileCollection.files.first()) }.get()
+            } else {
+                throw IllegalStateException(
+                    """
+                    App apk not specified or both appFileCollection and appFolder specified.
+                """
+                        .trimIndent()
+                )
+            }
+
+        val appApk =
+            appLoader.get().load(appDir)
+                ?: throw RuntimeException("Cannot load required APK for task: $name")
+
+        val appApkBuiltArtifact = appApk.elements.single()
+        val destinationApk = outputAppApk.get().asFile
+        File(appApkBuiltArtifact.outputFile).copyTo(destinationApk, overwrite = true)
+
+        val model =
+            singleFileAppApksModel(name = destinationApk.name, sha256 = sha256(destinationApk))
+        outputAppApksModel.get().asFile.writeText(model.toJson())
+    }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApksFromOutputProviderTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApksFromOutputProviderTask.kt
new file mode 100644
index 0000000..1898a53
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyApksFromOutputProviderTask.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+import com.android.build.api.variant.ApkInstallGroup
+import com.android.build.api.variant.ApkOutput
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/** Copy APKs (from ApkOutputProviders) needed for building androidTest.zip */
+@Suppress("UnstableApiUsage") // Working with ApkOutputProviders
+@DisableCachingByDefault(because = "Only filesystem operations")
+abstract class CopyApksFromOutputProviderTask
+@Inject
+constructor(private val fileSystemOperations: FileSystemOperations) : DefaultTask() {
+
+    /** File existence check to determine whether to run this task. */
+    @get:InputFiles
+    @get:SkipWhenEmpty
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val androidTestSourceCode: ConfigurableFileCollection
+
+    /** ApkOutputProviders output, contains all App APKs. */
+    @get:Internal abstract val apkOutput: Property<ApkOutput>
+
+    /** Some Variants includes test APK into ApkOutput, excluding it by using this parameter. */
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val excludeTestApk: RegularFileProperty
+
+    /**
+     * Filename prefix for all output apks. Required for producing unique filenames over all
+     * projects.
+     *
+     * Resulting filename: <outputFilenamesPrefix>-<groupIndex>-<fileIndex>-<originalFileName>
+     */
+    @get:Input abstract val outputFilenamesPrefix: Property<String>
+
+    @get:OutputDirectory abstract val outputDirectory: DirectoryProperty
+
+    @get:OutputFile abstract val outputAppApksModel: RegularFileProperty
+
+    @TaskAction
+    fun createApks() {
+        val outputDir = outputDirectory.get()
+
+        // Cleanup old files - to remove stale APKs
+        fileSystemOperations.delete { it.delete(outputDir) }
+
+        val fileNamePrefix = outputFilenamesPrefix.get()
+        val testApkSha256 = sha256(excludeTestApk.get().asFile)
+
+        val resultApkGroups =
+            apkOutput.get().apkInstallGroups.mapIndexedNotNull { groupIndex, installGroup ->
+                processApkInstallGroup(
+                    installGroup,
+                    testApkSha256,
+                    fileNamePrefix = "$fileNamePrefix-$groupIndex"
+                )
+            }
+
+        val model = AppApksModel(resultApkGroups)
+        outputAppApksModel.get().asFile.writeText(model.toJson())
+    }
+
+    private fun processApkInstallGroup(
+        installGroup: ApkInstallGroup,
+        excludeSha256: String,
+        fileNamePrefix: String
+    ): ApkFileGroup? {
+        val outputDir = outputDirectory.get()
+        val resultApkFiles =
+            installGroup.apks.mapIndexedNotNull { fileIndex, file ->
+                val inputFile = file.asFile
+                val fileSha256 = sha256(inputFile)
+                if (fileSha256 == excludeSha256) {
+                    // Some Variants includes test APK into ApkOutput, filter it.
+                    return@mapIndexedNotNull null
+                }
+
+                val outputFileName = "$fileNamePrefix-$fileIndex-${inputFile.name}"
+                val outputFile = outputDir.file(outputFileName).asFile
+
+                inputFile.copyTo(outputFile, overwrite = true)
+
+                return@mapIndexedNotNull ApkFile(name = outputFileName, sha256 = fileSha256)
+            }
+        return if (resultApkFiles.isEmpty()) {
+            null
+        } else {
+            ApkFileGroup(resultApkFiles)
+        }
+    }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyTestApksTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyTestApksTask.kt
index ffa92365..16986a0 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyTestApksTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/CopyTestApksTask.kt
@@ -18,18 +18,13 @@
 
 import com.android.build.api.variant.BuiltArtifactsLoader
 import java.io.File
-import javax.inject.Inject
 import org.gradle.api.DefaultTask
 import org.gradle.api.file.ConfigurableFileCollection
 import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.file.RegularFileProperty
-import org.gradle.api.model.ObjectFactory
 import org.gradle.api.provider.Property
-import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.InputFiles
 import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.Optional
-import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.OutputFile
 import org.gradle.api.tasks.PathSensitive
 import org.gradle.api.tasks.PathSensitivity
@@ -37,10 +32,14 @@
 import org.gradle.api.tasks.TaskAction
 import org.gradle.work.DisableCachingByDefault
 
-/** Copy APKs needed for building androidTest.zip */
+/**
+ * Copy test APK (androidTest or standalone test) needed for building androidTest.zip
+ *
+ * If test requires instrumented app apks, they will be copied separately in either
+ * [CopyApkFromArtifactsTask] or [CopyApksFromOutputProviderTask] depending on project type.
+ */
 @DisableCachingByDefault(because = "Only filesystem operations")
-abstract class CopyTestApksTask @Inject constructor(private val objects: ObjectFactory) :
-    DefaultTask() {
+abstract class CopyTestApksTask : DefaultTask() {
 
     /** File existence check to determine whether to run this task. */
     @get:InputFiles
@@ -52,121 +51,14 @@
     @get:PathSensitive(PathSensitivity.NONE)
     abstract val testFolder: DirectoryProperty
 
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.NONE)
-    abstract val appFolder: DirectoryProperty
-
-    @get:InputFiles
-    @get:PathSensitive(PathSensitivity.NONE)
-    abstract val appFileCollection: ConfigurableFileCollection
-
-    /**
-     * Extracted APKs for PrivacySandbox SDKs dependencies. Produced by AGP.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.RELATIVE) // We use parent folder for file name generation
-    abstract val privacySandboxSdkApks: ConfigurableFileCollection
-
-    /**
-     * Extracted split with manifest containing <uses-sdk-library> tag. Produced by AGP.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.NAME_ONLY)
-    abstract val privacySandboxUsesSdkSplit: ConfigurableFileCollection
-
-    /**
-     * Extracted compat splits for PrivacySandbox SDKs dependencies. Produced by AGP.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.NAME_ONLY)
-    abstract val privacySandboxSdkCompatSplits: ConfigurableFileCollection
-
-    /**
-     * Filename prefix for all PrivacySandbox related output files. Required for producing unique
-     * filenames over all projects.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:Input @get:Optional abstract val filenamePrefixForPrivacySandboxFiles: Property<String>
-
-    @get:Internal abstract val appLoader: Property<BuiltArtifactsLoader>
-
     @get:Internal abstract val testLoader: Property<BuiltArtifactsLoader>
 
     @get:OutputFile abstract val outputApplicationId: RegularFileProperty
 
     @get:OutputFile abstract val outputTestApk: RegularFileProperty
 
-    @get:[OutputFile Optional]
-    abstract val outputAppApk: RegularFileProperty
-
-    /**
-     * Output directory for PrivacySandbox SDKs APKs.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:[OutputDirectory Optional]
-    abstract val outputPrivacySandboxSdkApks: DirectoryProperty
-
-    /**
-     * Output directory for App splits required for devices with PrivacySandbox support.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:[OutputDirectory Optional]
-    abstract val outputPrivacySandboxAppSplits: DirectoryProperty
-
-    /**
-     * Output directory for App splits required for devices without PrivacySandbox support.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:[OutputDirectory Optional]
-    abstract val outputPrivacySandboxCompatAppSplits: DirectoryProperty
-
     @TaskAction
     fun createApks() {
-        if (appLoader.isPresent) {
-            // Decides where to load the app apk from, depending on whether appFolder or
-            // appFileCollection has been set.
-            val appDir =
-                if (appFolder.isPresent && appFileCollection.files.isEmpty()) {
-                    appFolder.get()
-                } else if (!appFolder.isPresent && appFileCollection.files.size == 1) {
-                    objects
-                        .directoryProperty()
-                        .also { it.set(appFileCollection.files.first()) }
-                        .get()
-                } else {
-                    throw IllegalStateException(
-                        """
-                    App apk not specified or both appFileCollection and appFolder specified.
-                """
-                            .trimIndent()
-                    )
-                }
-
-            val appApk =
-                appLoader.get().load(appDir)
-                    ?: throw RuntimeException("Cannot load required APK for task: $name")
-            // We don't need to check hasBenchmarkPlugin because benchmarks shouldn't have test apps
-            val appApkBuiltArtifact = appApk.elements.single()
-            val destinationApk = outputAppApk.get().asFile
-            File(appApkBuiltArtifact.outputFile).copyTo(destinationApk, overwrite = true)
-
-            createPrivacySandboxFiles()
-        }
-
         val testApk =
             testLoader.get().load(testFolder.get())
                 ?: throw RuntimeException("Cannot load required APK for task: $name")
@@ -175,42 +67,6 @@
         File(testApkBuiltArtifact.outputFile).copyTo(destinationApk, overwrite = true)
 
         val outputApplicationIdFile = outputApplicationId.get().asFile
-        outputApplicationIdFile.bufferedWriter().use { out -> out.write(testApk.applicationId) }
-    }
-
-    /**
-     * Creates APKs required for running App with PrivacySandbox SDKs. Do nothing if project doesn't
-     * have dependencies on PrivacySandbox SDKs.
-     */
-    private fun createPrivacySandboxFiles() {
-        if (privacySandboxSdkApks.isEmpty) {
-            return
-        }
-
-        val prefix = filenamePrefixForPrivacySandboxFiles.get()
-
-        privacySandboxSdkApks.asFileTree.map { sdkApk ->
-            // TODO (b/309610890): Remove after supporting unique filenames on bundletool side.
-            val sdkProjectName = sdkApk.parentFile?.name
-            val outputFileName = "$prefix-$sdkProjectName-${sdkApk.name}"
-            val outputFile = outputPrivacySandboxSdkApks.get().file(outputFileName)
-            sdkApk.copyTo(outputFile.asFile, overwrite = true)
-        }
-
-        val usesSdkSplitArtifact =
-            appLoader.get().load(privacySandboxUsesSdkSplit)?.elements?.single()
-        if (usesSdkSplitArtifact != null) {
-            val splitApk = File(usesSdkSplitArtifact.outputFile)
-            val outputFileName = "$prefix-${splitApk.name}"
-            val outputFile = outputPrivacySandboxAppSplits.get().file(outputFileName)
-            splitApk.copyTo(outputFile.asFile, overwrite = true)
-        }
-
-        appLoader.get().load(privacySandboxSdkCompatSplits)?.elements?.forEach { splitArtifact ->
-            val splitApk = File(splitArtifact.outputFile)
-            val outputFileName = "$prefix-${splitApk.name}"
-            val outputFile = outputPrivacySandboxCompatAppSplits.get().file(outputFileName)
-            splitApk.copyTo(outputFile.asFile, overwrite = true)
-        }
+        outputApplicationIdFile.writeText(testApk.applicationId)
     }
 }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
index 00bd055..49cade8 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/GenerateTestConfigurationTask.kt
@@ -45,10 +45,13 @@
 @DisableCachingByDefault(because = "Doesn't benefit from caching")
 abstract class GenerateTestConfigurationTask : DefaultTask() {
 
+    @get:Input abstract val testConfigType: Property<TestConfigType>
+
+    /** File containing [AppApksModel] with list of App APKs to install */
     @get:InputFile
     @get:Optional
-    @get:PathSensitive(PathSensitivity.NAME_ONLY)
-    abstract val appApk: RegularFileProperty
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val appApksModel: RegularFileProperty
 
     /** File existence check to determine whether to run this task. */
     @get:InputFiles
@@ -56,26 +59,6 @@
     @get:PathSensitive(PathSensitivity.NONE)
     abstract val androidTestSourceCodeCollection: ConfigurableFileCollection
 
-    /**
-     * Extracted APKs for PrivacySandbox SDKs dependencies. Produced by AGP.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.NAME_ONLY)
-    abstract val privacySandboxSdkApks: ConfigurableFileCollection
-
-    /**
-     * Extracted splits required for running app with PrivacySandbox SDKs. Produced by AGP.
-     *
-     * Should be set only for applications with PrivacySandbox SDKs dependencies.
-     */
-    @get:InputFiles
-    @get:Optional
-    @get:PathSensitive(PathSensitivity.NAME_ONLY)
-    abstract val privacySandboxAppSplits: ConfigurableFileCollection
-
     @get:InputFile
     @get:PathSensitive(PathSensitivity.NAME_ONLY)
     abstract val testApk: RegularFileProperty
@@ -117,22 +100,14 @@
         configurations testing Android Application projects, so that both APKs get installed.
          */
         val configBuilder = ConfigBuilder()
-        configBuilder.configName = outputXml.asFile.get().name
-        if (appApk.isPresent) {
-            val appApkFile = appApk.get().asFile
-            configBuilder.appApkName(appApkFile.name).appApkSha256(sha256(appApkFile))
+        configBuilder.configName(outputXml.asFile.get().name)
+        configBuilder.configType(testConfigType.get())
+        if (appApksModel.isPresent) {
+            val modelJson = appApksModel.get().asFile.readText()
+            val model = AppApksModel.fromJson(modelJson)
+            configBuilder.appApksModel(model)
         }
 
-        val privacySandboxSdkApksFileNames =
-            privacySandboxSdkApks.asFileTree.map { f -> f.name }.sorted()
-        if (privacySandboxSdkApksFileNames.isNotEmpty()) {
-            configBuilder.enablePrivacySandbox(true)
-            configBuilder.initialSetupApks(privacySandboxSdkApksFileNames)
-        }
-        val privacySandboxSplitsFileNames =
-            privacySandboxAppSplits.asFileTree.map { f -> f.name }.sorted()
-        configBuilder.appSplits(privacySandboxSplitsFileNames)
-
         configBuilder.additionalApkKeys(additionalApkKeys.get())
         val isPresubmit = presubmit.get()
         configBuilder.isPostsubmit(!isPresubmit)
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestConfigType.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestConfigType.kt
new file mode 100644
index 0000000..629dde3
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestConfigType.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+enum class TestConfigType {
+    /** Default test config type for all non privacy sandbox projects. */
+    DEFAULT,
+
+    /** Main test config type for privacy sandbox projects. SDK installed as separate package. */
+    PRIVACY_SANDBOX_MAIN,
+
+    /** Compat test config type for privacy sandbox projects. SDK installed as app split. */
+    PRIVACY_SANDBOX_COMPAT;
+
+    fun isAddedToInstrumentationArgs(): Boolean = this != DEFAULT
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSourceSetsHelper.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSourceSetsHelper.kt
new file mode 100644
index 0000000..3cf5bb4
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSourceSetsHelper.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 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 androidx.build.testConfiguration
+
+import androidx.build.multiplatformExtension
+import com.android.build.api.variant.TestVariant
+import com.android.build.api.variant.Variant
+import org.gradle.api.Project
+import org.gradle.api.file.FileCollection
+import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
+import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
+
+internal fun Project.getTestSourceSetsForAndroid(variant: Variant?): List<FileCollection> {
+    val testSourceFileCollections = mutableListOf<FileCollection>()
+    when (variant) {
+        is TestVariant -> {
+            // com.android.test modules keep test code in main sourceset
+            variant.sources.java?.all?.let { sourceSet ->
+                testSourceFileCollections.add(files(sourceSet))
+            }
+            // Add kotlin-android main source set
+            extensions
+                .findByType(KotlinAndroidProjectExtension::class.java)
+                ?.sourceSets
+                ?.find { it.name == "main" }
+                ?.let { testSourceFileCollections.add(it.kotlin.sourceDirectories) }
+            // Note, don't have to add kotlin-multiplatform as it is not compatible with
+            // com.android.test modules
+        }
+        is com.android.build.api.variant.HasAndroidTest -> {
+            variant.androidTest?.sources?.java?.all?.let {
+                testSourceFileCollections.add(files(it))
+            }
+        }
+    }
+
+    // Add kotlin-android androidTest source set
+    extensions
+        .findByType(KotlinAndroidProjectExtension::class.java)
+        ?.sourceSets
+        ?.find { it.name == "androidTest" }
+        ?.let { testSourceFileCollections.add(it.kotlin.sourceDirectories) }
+
+    // Add kotlin-multiplatform androidInstrumentedTest target source sets
+    multiplatformExtension
+        ?.targets
+        ?.filterIsInstance<KotlinAndroidTarget>()
+        ?.mapNotNull { it.compilations.find { it.name == "releaseAndroidTest" } }
+        ?.flatMap { it.allKotlinSourceSets }
+        ?.mapTo(testSourceFileCollections) { it.kotlin.sourceDirectories }
+    return testSourceFileCollections
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
index 498167c..32568c2 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
@@ -13,49 +13,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@file:Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
 
 package androidx.build.testConfiguration
 
 import androidx.build.AndroidXExtension
 import androidx.build.AndroidXImplPlugin.Companion.FINALIZE_TEST_CONFIGS_WITH_APKS_TASK
-import androidx.build.DeprecatedKotlinMultiplatformAndroidTarget
 import androidx.build.androidXExtension
 import androidx.build.asFilenamePrefix
 import androidx.build.dependencyTracker.AffectedModuleDetector
 import androidx.build.getFileInTestConfigDirectory
-import androidx.build.getPrivacySandboxFilesDirectory
 import androidx.build.hasBenchmarkPlugin
 import androidx.build.isMacrobenchmark
 import androidx.build.isPresubmitBuild
-import androidx.build.multiplatformExtension
 import com.android.build.api.artifact.Artifacts
 import com.android.build.api.artifact.SingleArtifact
 import com.android.build.api.attributes.BuildTypeAttr
 import com.android.build.api.dsl.ApplicationExtension
 import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
 import com.android.build.api.dsl.TestExtension
 import com.android.build.api.variant.AndroidComponentsExtension
+import com.android.build.api.variant.ApkOutputProviders
 import com.android.build.api.variant.ApplicationAndroidComponentsExtension
 import com.android.build.api.variant.HasDeviceTests
 import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
 import com.android.build.api.variant.LibraryAndroidComponentsExtension
 import com.android.build.api.variant.TestAndroidComponentsExtension
-import com.android.build.api.variant.TestVariant
 import com.android.build.api.variant.Variant
+import java.util.function.Consumer
 import kotlin.math.max
 import org.gradle.api.Project
 import org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE
 import org.gradle.api.attributes.Usage
-import org.gradle.api.file.Directory
-import org.gradle.api.file.FileCollection
 import org.gradle.api.file.RegularFile
 import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.TaskProvider
 import org.gradle.kotlin.dsl.getByType
 import org.gradle.kotlin.dsl.named
-import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
-import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
 
 /**
  * Creates and configures the test config generation task for a project. Configuration includes
@@ -80,13 +74,11 @@
         */
         registerGenerateTestConfigurationTask(
             "${GENERATE_PRIVACY_SANDBOX_MAIN_TEST_CONFIGURATION_TASK}$variantName",
+            TestConfigType.PRIVACY_SANDBOX_MAIN,
             xmlName = "${path.asFilenamePrefix()}$variantName.xml",
             jsonName = null, // Privacy sandbox not yet supported in JSON configs
             copyTestApksTask.flatMap { it.outputApplicationId },
-            copyTestApksTask.flatMap { it.outputAppApk },
             copyTestApksTask.flatMap { it.outputTestApk },
-            copyTestApksTask.flatMap { it.outputPrivacySandboxSdkApks },
-            copyTestApksTask.flatMap { it.outputPrivacySandboxAppSplits },
             minSdk = max(minSdk, PRIVACY_SANDBOX_MIN_API_LEVEL),
             testRunner,
             instrumentationRunnerArgs,
@@ -96,13 +88,11 @@
 
         registerGenerateTestConfigurationTask(
             "${GENERATE_PRIVACY_SANDBOX_COMPAT_TEST_CONFIGURATION_TASK}${variantName}",
+            TestConfigType.PRIVACY_SANDBOX_COMPAT,
             xmlName = "${path.asFilenamePrefix()}${variantName}Compat.xml",
             jsonName = null, // Privacy sandbox not yet supported in JSON configs
             copyTestApksTask.flatMap { it.outputApplicationId },
-            copyTestApksTask.flatMap { it.outputAppApk },
             copyTestApksTask.flatMap { it.outputTestApk },
-            privacySandboxApks = null,
-            copyTestApksTask.flatMap { it.outputPrivacySandboxCompatAppSplits },
             minSdk,
             testRunner,
             instrumentationRunnerArgs,
@@ -112,13 +102,11 @@
     } else {
         registerGenerateTestConfigurationTask(
             "${GENERATE_TEST_CONFIGURATION_TASK}$variantName",
+            TestConfigType.DEFAULT,
             xmlName = "${path.asFilenamePrefix()}$variantName.xml",
             jsonName = "_${path.asFilenamePrefix()}$variantName.json",
             copyTestApksTask.flatMap { it.outputApplicationId },
-            copyTestApksTask.flatMap { it.outputAppApk },
             copyTestApksTask.flatMap { it.outputTestApk },
-            privacySandboxApks = null,
-            privacySandboxSplits = null,
             minSdk,
             testRunner,
             instrumentationRunnerArgs,
@@ -154,13 +142,11 @@
 
 private fun Project.registerGenerateTestConfigurationTask(
     taskName: String,
+    configType: TestConfigType,
     xmlName: String,
     jsonName: String?,
     applicationIdFile: Provider<RegularFile>,
-    appApk: Provider<RegularFile>,
     testApk: Provider<RegularFile>,
-    privacySandboxApks: Provider<Directory>?,
-    privacySandboxSplits: Provider<Directory>?,
     minSdk: Int,
     testRunner: Provider<String>,
     instrumentationRunnerArgs: Provider<Map<String, String>>,
@@ -169,12 +155,10 @@
 ) {
     val generateTestConfigurationTask =
         tasks.register(taskName, GenerateTestConfigurationTask::class.java) { task ->
-            task.applicationId.set(project.providers.fileContents(applicationIdFile).asText)
-            task.appApk.set(appApk)
-            task.testApk.set(testApk)
+            task.testConfigType.set(configType)
 
-            privacySandboxApks?.let { task.privacySandboxSdkApks.from(it) }
-            privacySandboxSplits?.let { task.privacySandboxAppSplits.from(it) }
+            task.applicationId.set(project.providers.fileContents(applicationIdFile).asText)
+            task.testApk.set(testApk)
 
             val androidXExtension = extensions.getByType<AndroidXExtension>()
             task.additionalApkKeys.set(androidXExtension.additionalDeviceTestApkKeys)
@@ -222,65 +206,28 @@
         return getFileInTestConfigDirectory(filename)
     }
 
-    fun addPrivacySandboxApksFor(variant: Variant, task: CopyTestApksTask) {
-        // TODO (b/309610890): Replace for dependency on AGP artifact.
-        val extractedPrivacySandboxSdkApksDir =
-            layout.buildDirectory.dir("intermediates/extracted_apks_from_privacy_sandbox_sdks")
-        task.privacySandboxSdkApks.from(
-            files(extractedPrivacySandboxSdkApksDir) {
-                it.builtBy("buildPrivacySandboxSdkApksForDebug")
-            }
-        )
-        // TODO (b/309610890): Replace for dependency on AGP artifact.
-        val usesSdkSplitDir =
-            layout.buildDirectory.dir("intermediates/uses_sdk_library_split_for_local_deployment")
-        task.privacySandboxUsesSdkSplit.from(
-            files(usesSdkSplitDir) {
-                it.builtBy("generateDebugAdditionalSplitForPrivacySandboxDeployment")
-            }
-        )
-        // TODO (b/309610890): Replace for dependency on AGP artifact.
-        val extractedPrivacySandboxCompatSplitsDir =
-            layout.buildDirectory.dir("intermediates/extracted_sdk_apks")
-        task.privacySandboxSdkCompatSplits.from(
-            files(extractedPrivacySandboxCompatSplitsDir) {
-                it.builtBy("extractApksFromSdkSplitsForDebug")
-            }
-        )
-        task.filenamePrefixForPrivacySandboxFiles.set("${path.asFilenamePrefix()}-${variant.name}")
-        task.outputPrivacySandboxSdkApks.set(
-            getPrivacySandboxFilesDirectory().map {
-                it.dir("${path.asFilenamePrefix()}-${variant.name}-sdks")
-            }
-        )
-        task.outputPrivacySandboxAppSplits.set(
-            getPrivacySandboxFilesDirectory().map {
-                it.dir("${path.asFilenamePrefix()}-${variant.name}-app-splits")
-            }
-        )
-        task.outputPrivacySandboxCompatAppSplits.set(
-            getPrivacySandboxFilesDirectory().map {
-                it.dir("${path.asFilenamePrefix()}-${variant.name}-compat-app-splits")
-            }
-        )
-    }
-
     // For application modules, the instrumentation apk is generated in the module itself
     extensions.findByType(ApplicationAndroidComponentsExtension::class.java)?.apply {
         onVariants(selector().withBuildType("debug")) { variant ->
-            tasks.named(
-                "${COPY_TEST_APKS_TASK}${variant.name}AndroidTest",
-                CopyTestApksTask::class.java
-            ) { task ->
-                task.appFolder.set(variant.artifacts.get(SingleArtifact.APK))
-                task.appLoader.set(variant.artifacts.getBuiltArtifactsLoader())
+            if (isPrivacySandboxEnabled()) {
+                @Suppress("UnstableApiUsage") // variant.outputProviders
+                addAppApksToPrivacySandboxTestConfigsGeneration(
+                    testVariantName = "${variant.name}AndroidTest",
+                    variant,
+                    variant.outputProviders
+                )
+            } else {
+                // TODO(b/347956800): Migrate to ApkOutputProviders after testing on PrivacySandbox
+                addAppApkFromArtifactsToTestConfigGeneration(
+                    testVariantName = "${variant.name}AndroidTest",
+                    variant,
+                    configureAction = { task ->
+                        task.appFolder.set(variant.artifacts.get(SingleArtifact.APK))
 
-                // The target project is the same being evaluated
-                task.outputAppApk.set(outputAppApkFile(variant, path, null))
-
-                if (isPrivacySandboxEnabled()) {
-                    addPrivacySandboxApksFor(variant, task)
-                }
+                        // The target project is the same being evaluated
+                        task.outputAppApk.set(outputAppApkFile(variant, path, null))
+                    }
+                )
             }
         }
     }
@@ -291,33 +238,46 @@
     // from the application one.
     extensions.findByType(TestAndroidComponentsExtension::class.java)?.apply {
         onVariants(selector().all()) { variant ->
-            tasks.named("${COPY_TEST_APKS_TASK}${variant.name}", CopyTestApksTask::class.java) {
-                task ->
-                task.appLoader.set(variant.artifacts.getBuiltArtifactsLoader())
-
-                // The target app path is defined in the targetProjectPath field in the android
-                // extension of the test module
-                val targetProjectPath =
-                    project.extensions.getByType(TestExtension::class.java).targetProjectPath
-                        ?: throw IllegalStateException(
+            if (isPrivacySandboxEnabled()) {
+                @Suppress("UnstableApiUsage") // variant.outputProviders
+                addAppApksToPrivacySandboxTestConfigsGeneration(
+                    testVariantName = variant.name,
+                    variant,
+                    variant.outputProviders
+                )
+            } else {
+                // TODO(b/347956800): Migrate to ApkOutputProviders after b/378675038
+                addAppApkFromArtifactsToTestConfigGeneration(
+                    testVariantName = variant.name,
+                    variant,
+                    configureAction = { task ->
+                        // The target app path is defined in the targetProjectPath field in the
+                        // android extension of the test module
+                        val targetProjectPath =
+                            project.extensions
+                                .getByType(TestExtension::class.java)
+                                .targetProjectPath
+                                ?: throw IllegalStateException(
+                                    """
+                                Module `$path` does not have a targetProjectPath defined.
                             """
-                        Module `$path` does not have a targetProjectPath defined.
-                    """
-                                .trimIndent()
-                        )
-                task.outputAppApk.set(outputAppApkFile(variant, targetProjectPath, path))
+                                        .trimIndent()
+                                )
+                        task.outputAppApk.set(outputAppApkFile(variant, targetProjectPath, path))
 
-                task.appFileCollection.from(
-                    configurations
-                        .named("${variant.name}TestedApks")
-                        .get()
-                        .incoming
-                        .artifactView {
-                            it.attributes { container ->
-                                container.attribute(ARTIFACT_TYPE_ATTRIBUTE, "apk")
-                            }
-                        }
-                        .files
+                        task.appFileCollection.from(
+                            configurations
+                                .named("${variant.name}TestedApks")
+                                .get()
+                                .incoming
+                                .artifactView {
+                                    it.attributes { container ->
+                                        container.attribute(ARTIFACT_TYPE_ATTRIBUTE, "apk")
+                                    }
+                                }
+                                .files
+                        )
+                    }
                 )
             }
         }
@@ -349,27 +309,75 @@
                     config.dependencies.add(project.dependencyFactory.create(targetAppProject))
                 }
 
-            tasks.named(
-                "${COPY_TEST_APKS_TASK}${variant.name}AndroidTest",
-                CopyTestApksTask::class.java
-            ) { task ->
-                task.appLoader.set(variant.artifacts.getBuiltArtifactsLoader())
+            addAppApkFromArtifactsToTestConfigGeneration(
+                testVariantName = "${variant.name}AndroidTest",
+                variant,
+                configureAction = { task ->
+                    // The target app path is defined in the androidx extension
+                    task.outputAppApk.set(outputAppApkFile(variant, targetAppProject.path, path))
 
-                // The target app path is defined in the androidx extension
-                task.outputAppApk.set(outputAppApkFile(variant, targetAppProject.path, path))
-
-                task.appFileCollection.from(
-                    configuration.incoming
-                        .artifactView { view ->
-                            view.attributes { it.attribute(ARTIFACT_TYPE_ATTRIBUTE, "apk") }
-                        }
-                        .files
-                )
-            }
+                    task.appFileCollection.from(
+                        configuration.incoming
+                            .artifactView { view ->
+                                view.attributes { it.attribute(ARTIFACT_TYPE_ATTRIBUTE, "apk") }
+                            }
+                            .files
+                    )
+                }
+            )
         }
     }
 }
 
+private fun Project.addAppApkFromArtifactsToTestConfigGeneration(
+    testVariantName: String,
+    variant: Variant,
+    configureAction: Consumer<CopyApkFromArtifactsTask>
+) {
+    val copyApkTask = registerCopyAppApkFromArtifactsTask(variant, configureAction)
+    tasks.named(
+        "${GENERATE_TEST_CONFIGURATION_TASK}$testVariantName",
+        GenerateTestConfigurationTask::class.java
+    ) { t ->
+        t.appApksModel.set(copyApkTask.flatMap(CopyApkFromArtifactsTask::outputAppApksModel))
+    }
+}
+
+// TODO(b/347956800): Call from createTestConfigurationGenerationTask() after b/378674313
+// TODO(b/347956800): Use tasks providers directly instead of tasks.named after b/378674313
+@Suppress("UnstableApiUsage") // ApkOutputProviders
+private fun Project.addAppApksToPrivacySandboxTestConfigsGeneration(
+    testVariantName: String,
+    variant: Variant,
+    outputProviders: ApkOutputProviders,
+) {
+    val copyTestApksTask =
+        tasks.named("${COPY_TEST_APKS_TASK}${testVariantName}", CopyTestApksTask::class.java)
+    val excludeTestApk = copyTestApksTask.flatMap(CopyTestApksTask::outputTestApk)
+
+    val copyMainApksTask =
+        registerCopyPrivacySandboxMainAppApksTask(variant, outputProviders, excludeTestApk)
+    tasks.named(
+        "${GENERATE_PRIVACY_SANDBOX_MAIN_TEST_CONFIGURATION_TASK}${testVariantName}",
+        GenerateTestConfigurationTask::class.java
+    ) { t ->
+        t.appApksModel.set(
+            copyMainApksTask.flatMap(CopyApksFromOutputProviderTask::outputAppApksModel)
+        )
+    }
+
+    val copyCompatApksTask =
+        registerCopyPrivacySandboxCompatAppApksTask(variant, outputProviders, excludeTestApk)
+    tasks.named(
+        "${GENERATE_PRIVACY_SANDBOX_COMPAT_TEST_CONFIGURATION_TASK}${testVariantName}",
+        GenerateTestConfigurationTask::class.java
+    ) { t ->
+        t.appApksModel.set(
+            copyCompatApksTask.flatMap(CopyApksFromOutputProviderTask::outputAppApksModel)
+        )
+    }
+}
+
 private fun getOrCreateMediaTestConfigTask(
     project: Project
 ): TaskProvider<GenerateMediaTestConfigurationTask> {
@@ -564,7 +572,7 @@
 
 @Suppress("UnstableApiUsage") // HasDeviceTests is @Incubating b/372495504
 fun Project.configureTestConfigGeneration(
-    kotlinMultiplatformAndroidTarget: DeprecatedKotlinMultiplatformAndroidTarget,
+    kotlinMultiplatformAndroidTarget: KotlinMultiplatformAndroidLibraryTarget,
     componentsExtension: KotlinMultiplatformAndroidComponentsExtension,
     projectIsolationEnabled: Boolean,
 ) {
@@ -584,49 +592,10 @@
     }
 }
 
-private fun Project.getTestSourceSetsForAndroid(variant: Variant?): List<FileCollection> {
-    val testSourceFileCollections = mutableListOf<FileCollection>()
-    when (variant) {
-        is TestVariant -> {
-            // com.android.test modules keep test code in main sourceset
-            variant.sources.java?.all?.let { sourceSet ->
-                testSourceFileCollections.add(files(sourceSet))
-            }
-            // Add kotlin-android main source set
-            extensions
-                .findByType(KotlinAndroidProjectExtension::class.java)
-                ?.sourceSets
-                ?.find { it.name == "main" }
-                ?.let { testSourceFileCollections.add(it.kotlin.sourceDirectories) }
-            // Note, don't have to add kotlin-multiplatform as it is not compatible with
-            // com.android.test modules
-        }
-        is com.android.build.api.variant.HasAndroidTest -> {
-            variant.androidTest?.sources?.java?.all?.let {
-                testSourceFileCollections.add(files(it))
-            }
-        }
-    }
-
-    // Add kotlin-android androidTest source set
-    extensions
-        .findByType(KotlinAndroidProjectExtension::class.java)
-        ?.sourceSets
-        ?.find { it.name == "androidTest" }
-        ?.let { testSourceFileCollections.add(it.kotlin.sourceDirectories) }
-
-    // Add kotlin-multiplatform androidInstrumentedTest target source sets
-    multiplatformExtension
-        ?.targets
-        ?.filterIsInstance<KotlinAndroidTarget>()
-        ?.mapNotNull { it.compilations.find { it.name == "releaseAndroidTest" } }
-        ?.flatMap { it.allKotlinSourceSets }
-        ?.mapTo(testSourceFileCollections) { it.kotlin.sourceDirectories }
-    return testSourceFileCollections
-}
-
 private fun Project.isPrivacySandboxEnabled(): Boolean =
-    extensions.findByType(ApplicationExtension::class.java)?.privacySandbox?.enable ?: false
+    extensions.findByType(ApplicationExtension::class.java)?.privacySandbox?.enable
+        ?: extensions.findByType(TestExtension::class.java)?.privacySandbox?.enable
+        ?: false
 
 private const val COPY_TEST_APKS_TASK = "CopyTestApks"
 private const val GENERATE_PRIVACY_SANDBOX_MAIN_TEST_CONFIGURATION_TASK =
@@ -634,4 +603,3 @@
 private const val GENERATE_PRIVACY_SANDBOX_COMPAT_TEST_CONFIGURATION_TASK =
     "GeneratePrivacySandboxCompatTestConfiguration"
 private const val GENERATE_TEST_CONFIGURATION_TASK = "GenerateTestConfiguration"
-private const val PRIVACY_SANDBOX_MIN_API_LEVEL = 34
diff --git a/buildSrc/public/src/main/kotlin/androidx/build/AndroidXExtension.kt b/buildSrc/public/src/main/kotlin/androidx/build/AndroidXExtension.kt
index e6e68f7..99b1d02 100644
--- a/buildSrc/public/src/main/kotlin/androidx/build/AndroidXExtension.kt
+++ b/buildSrc/public/src/main/kotlin/androidx/build/AndroidXExtension.kt
@@ -16,7 +16,9 @@
 
 package androidx.build
 
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
 import groovy.lang.Closure
+import java.io.File
 import javax.inject.Inject
 import org.gradle.api.GradleException
 import org.gradle.api.Project
@@ -324,6 +326,7 @@
     fun isPublishConfigured(): Boolean = (publish != Publish.UNSET || type.publish != Publish.UNSET)
 
     fun shouldPublishSbom(): Boolean {
+        if (isIsolatedProjectsEnabled()) return false
         // IDE plugins are used by and ship inside Studio
         return shouldPublish() || type == LibraryType.IDE_PLUGIN
     }
@@ -441,6 +444,20 @@
         samplesProjects.add(samplesProject)
     }
 
+    /** Adds golden image assets to Android test APKs to use for screenshot tests. */
+    fun addGoldenImageAssets() {
+        project.extensions.findByType(LibraryAndroidComponentsExtension::class.java)?.onVariants {
+            variant ->
+            val subdirectory = project.path.replace(":", "/")
+            variant.androidTest
+                ?.sources
+                ?.assets
+                ?.addStaticSourceDirectory(
+                    File(project.rootDir, "../../golden$subdirectory").absolutePath
+                )
+        }
+    }
+
     /** Locates a project by path. */
     // This method is needed for Gradle project isolation to avoid calls to parent projects due to
     // androidx { samples(project(":foo")) }
diff --git a/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt b/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
index 2e991a7..4b8281b 100644
--- a/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
+++ b/buildSrc/public/src/main/kotlin/androidx/build/BuildServerConfiguration.kt
@@ -78,9 +78,9 @@
 fun Project.getTestConfigDirectory(): Provider<Directory> =
     rootProject.layout.buildDirectory.dir("test-xml-configs")
 
-/** Directory for PrivacySandbox related APKs (SDKs, compat splits) used in device tests. */
-fun Project.getPrivacySandboxFilesDirectory(): Provider<Directory> =
-    rootProject.layout.buildDirectory.dir("privacysandbox-files")
+/** Directory for App APKs (from ApkOutputProviders) used in device tests. */
+fun Project.getAppApksFilesDirectory(): Provider<Directory> =
+    rootProject.layout.buildDirectory.dir("app-apks-files")
 
 /** A file within [getTestConfigDirectory] */
 fun Project.getFileInTestConfigDirectory(name: String): Provider<RegularFile> =
@@ -106,3 +106,7 @@
     directory.mkdirs()
     return directory
 }
+
+/** Directory in a maven format to put per project publishing artifacts. */
+fun Project.getPerProjectRepositoryDirectory(): Provider<Directory> =
+    project.layout.buildDirectory.dir("repository")
diff --git a/buildSrc/public/src/main/kotlin/androidx/build/LibraryType.kt b/buildSrc/public/src/main/kotlin/androidx/build/LibraryType.kt
index 4d0e98c..b0be6fb 100644
--- a/buildSrc/public/src/main/kotlin/androidx/build/LibraryType.kt
+++ b/buildSrc/public/src/main/kotlin/androidx/build/LibraryType.kt
@@ -26,11 +26,8 @@
  * purpose of the library needs to be set, rather than a variety of more arcane options.
  *
  * These properties are as follows: LibraryType.publish represents how the library is published to
- * GMaven LibraryType.sourceJars represents whether we publish the source code for the library to
- * GMaven in a way accessible to download, such as by Android Studio LibraryType.generateDocs
- * represents whether we generate documentation from the library to put on developer.android.com
- * LibraryType.checkApi represents whether we enforce API compatibility of the library according to
- * our semantic versioning protocol
+ * GMaven LibraryType.checkApi represents whether we enforce API compatibility of the library
+ * according to our semantic versioning protocol
  *
  * The possible values of LibraryType are as follows:
  * - [PUBLISHED_LIBRARY]: a conventional library published, sourced, documented, and versioned.
@@ -60,7 +57,6 @@
  */
 sealed class LibraryType(
     val publish: Publish = Publish.NONE,
-    val sourceJars: Boolean = false,
     val checkApi: RunApiTasks = RunApiTasks.No("Unknown Library Type"),
     val compilationTarget: CompilationTarget = CompilationTarget.DEVICE,
     val allowCallingVisibleForTestsApis: Boolean = false,
@@ -131,7 +127,6 @@
     ) :
         LibraryType(
             publish = Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = true,
             checkApi = checkApi,
             allowCallingVisibleForTestsApis = allowCallingVisibleForTestsApis,
             targetsKotlinConsumersOnly = targetsKotlinConsumersOnly
@@ -160,14 +155,12 @@
     class Samples :
         LibraryType(
             publish = Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = true,
             checkApi = RunApiTasks.No("Sample Library")
         )
 
     class Lint :
         LibraryType(
             publish = Publish.NONE,
-            sourceJars = false,
             checkApi = RunApiTasks.No("Lint Library"),
             compilationTarget = CompilationTarget.HOST
         )
@@ -175,39 +168,13 @@
     class StandalonePublishedLint :
         LibraryType(
             publish = Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = true,
             checkApi = RunApiTasks.No("Lint Library"),
             compilationTarget = CompilationTarget.HOST
         )
 
-    class CompilerDaemon :
-        LibraryType(
-            Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = false,
-            RunApiTasks.No("Compiler Daemon (Host-only)"),
-            CompilationTarget.HOST
-        )
-
-    class CompilerDaemonTest :
-        LibraryType(
-            Publish.NONE,
-            sourceJars = false,
-            RunApiTasks.No("Compiler Daemon (Host-only) Test"),
-            CompilationTarget.HOST
-        )
-
-    class CompilerPlugin :
-        LibraryType(
-            Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = false,
-            RunApiTasks.No("Compiler Plugin (Host-only)"),
-            CompilationTarget.HOST
-        )
-
     class GradlePlugin :
         LibraryType(
             Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = false,
             RunApiTasks.No("Gradle Plugin (Host-only)"),
             CompilationTarget.HOST
         )
@@ -215,7 +182,6 @@
     class AnnotationProcessor :
         LibraryType(
             publish = Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = false,
             checkApi = RunApiTasks.No("Annotation Processor"),
             compilationTarget = CompilationTarget.HOST
         )
@@ -223,7 +189,6 @@
     class AnnotationProcessorUtils :
         LibraryType(
             publish = Publish.SNAPSHOT_AND_RELEASE,
-            sourceJars = true,
             checkApi = RunApiTasks.No("Annotation Processor Helper Library"),
             compilationTarget = CompilationTarget.HOST
         )
@@ -231,7 +196,6 @@
     class OtherCodeProcessor(publish: Publish = Publish.SNAPSHOT_AND_RELEASE) :
         LibraryType(
             publish = publish,
-            sourceJars = false,
             checkApi = RunApiTasks.No("Code Processor (Host-only)"),
             compilationTarget = CompilationTarget.HOST
         )
@@ -239,7 +203,6 @@
     class IdePlugin :
         LibraryType(
             publish = Publish.NONE,
-            sourceJars = false,
             // TODO: figure out a way to make sure we don't break Studio
             checkApi = RunApiTasks.No("IDE Plugin (consumed only by Android Studio"),
             // This is a bit complicated. IDE plugins usually have an on-device component installed
diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle
index a73e8cf..c84b8d1 100644
--- a/buildSrc/settings.gradle
+++ b/buildSrc/settings.gradle
@@ -35,6 +35,7 @@
 include ":imports:room-gradle-plugin"
 include ":imports:glance-layout-generator"
 include ":imports:stableaidl-gradle-plugin"
+include ":imports:privacysandbox-gradle-plugin"
 
 dependencyResolutionManagement {
     versionCatalogs {
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
index 5ad87d7..e0984c9 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
@@ -213,12 +213,10 @@
         return streamConfigurationMapCompat.getOutputFormats()?.toSet() ?: emptySet()
     }
 
-    @SuppressLint("ClassVerificationFailure")
     override fun getSupportedResolutions(format: Int): List<Size> {
         return streamConfigurationMapCompat.getOutputSizes(format)?.toList() ?: emptyList()
     }
 
-    @SuppressLint("ClassVerificationFailure")
     override fun getSupportedHighResolutions(format: Int): List<Size> {
         return streamConfigurationMapCompat.getHighResolutionOutputSizes(format)?.toList()
             ?: emptyList()
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
index 20be397..19370bd 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
@@ -21,8 +21,11 @@
 import androidx.annotation.OptIn
 import androidx.camera.camera2.pipe.CameraMetadata.Companion.isHardwareLevelLegacy
 import androidx.camera.camera2.pipe.FrameInfo
+import androidx.camera.camera2.pipe.FrameNumber
 import androidx.camera.camera2.pipe.InputRequest
 import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.RequestFailure
+import androidx.camera.camera2.pipe.RequestMetadata
 import androidx.camera.camera2.pipe.RequestTemplate
 import androidx.camera.camera2.pipe.integration.compat.workaround.TemplateParamsOverride
 import androidx.camera.camera2.pipe.integration.config.UseCaseCameraScope
@@ -35,9 +38,11 @@
 import androidx.camera.camera2.pipe.integration.impl.toParameters
 import androidx.camera.camera2.pipe.media.AndroidImage
 import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.ImageProxy
 import androidx.camera.core.impl.CameraCaptureResults
 import androidx.camera.core.impl.CaptureConfig
 import androidx.camera.core.impl.Config
+import java.util.concurrent.atomic.AtomicReference
 import javax.inject.Inject
 
 /**
@@ -112,6 +117,7 @@
         }
 
         var inputRequest: InputRequest? = null
+        var captureCallback: Request.Listener? = null
         var requestTemplateToSubmit = RequestTemplate(captureConfig.templateType)
         if (
             captureConfig.templateType == CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG &&
@@ -127,6 +133,13 @@
                     val imageWrapper = AndroidImage(checkNotNull(imageProxy.image))
                     val frameInfo = checkNotNull(cameraCaptureResult.unwrapAs(FrameInfo::class))
                     inputRequest = InputRequest(imageWrapper, frameInfo)
+
+                    // It's essential to call ImageProxy#close().
+                    // To ensure the ImageProxy is closed after the image is written to the output
+                    // surface. This is crucial to prevent resource leaks, where images might not
+                    // be closed properly if CameraX fails to propagate close events to its internal
+                    // components.
+                    captureCallback = buildImageClosingRequestListener(imageProxy)
                 }
             }
         }
@@ -140,10 +153,15 @@
         val parameters =
             templateParamsOverride.getOverrideParams(requestTemplateToSubmit) +
                 optionBuilder.build().toParameters()
+        val requestListeners = buildList {
+            add(callbacks)
+            captureCallback?.let { add(it) }
+            addAll(additionalListeners)
+        }
 
         return Request(
             streams = streamIdList,
-            listeners = listOf(callbacks) + additionalListeners,
+            listeners = requestListeners,
             parameters = parameters,
             extras = mapOf(CAMERAX_TAG_BUNDLE to captureConfig.tagBundle),
             template = requestTemplateToSubmit,
@@ -151,6 +169,44 @@
         )
     }
 
+    private fun buildImageClosingRequestListener(imageProxy: ImageProxy): Request.Listener {
+        val imageProxyToClose = AtomicReference(imageProxy)
+
+        fun closeImageProxy() {
+            imageProxyToClose.getAndSet(null)?.close()
+        }
+
+        return object : Request.Listener {
+            override fun onComplete(
+                requestMetadata: RequestMetadata,
+                frameNumber: FrameNumber,
+                result: FrameInfo
+            ) {
+                closeImageProxy()
+            }
+
+            override fun onFailed(
+                requestMetadata: RequestMetadata,
+                frameNumber: FrameNumber,
+                requestFailure: RequestFailure
+            ) {
+                closeImageProxy()
+            }
+
+            override fun onAborted(request: Request) {
+                closeImageProxy()
+            }
+
+            override fun onTotalCaptureResult(
+                requestMetadata: RequestMetadata,
+                frameNumber: FrameNumber,
+                totalCaptureResult: FrameInfo
+            ) {
+                closeImageProxy()
+            }
+        }
+    }
+
     public companion object {
         internal fun CaptureConfig.getStillCaptureTemplate(
             sessionTemplate: RequestTemplate,
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
index 9f6da5a..0c7d723 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/SupportedSurfaceCombination.kt
@@ -16,7 +16,6 @@
 
 package androidx.camera.camera2.pipe.integration.adapter
 
-import android.annotation.SuppressLint
 import android.content.Context
 import android.content.pm.PackageManager
 import android.content.pm.PackageManager.FEATURE_CAMERA_CONCURRENT
@@ -1512,7 +1511,6 @@
      * @param highResolutionIncluded whether high resolution output sizes are included
      * @return the max supported output size for the image format
      */
-    @SuppressLint("ClassVerificationFailure")
     internal fun getMaxOutputSizeByFormat(
         map: StreamConfigurationMap?,
         imageFormat: Int,
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt
index 609fce5..de5c7a3 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt
@@ -117,6 +117,10 @@
 
         Log.debug { "Prepare UseCaseCameraGraphConfig: $cameraGraph " }
 
+        // Start the CameraGraph first before setting up Surfaces. Surfaces can be closed, and we
+        // will close the CameraGraph when that happens, and we cannot start a closed CameraGraph.
+        cameraGraph.start()
+
         if (!sessionConfigAdapter.isSessionProcessorEnabled) {
             Log.debug { "Setting up Surfaces with UseCaseSurfaceManager" }
             if (sessionConfigAdapter.isSessionConfigValid()) {
@@ -128,8 +132,7 @@
                     )
                     .invokeOnCompletion { throwable ->
                         // Only show logs for error cases, ignore CancellationException since the
-                        // task
-                        // could be cancelled by UseCaseSurfaceManager#stopAsync().
+                        // task could be cancelled by UseCaseSurfaceManager#stopAsync().
                         if (throwable != null && throwable !is CancellationException) {
                             Log.error(throwable) { "Surface setup error!" }
                         }
@@ -139,8 +142,6 @@
             }
         }
 
-        cameraGraph.start()
-
         return UseCaseGraphConfig(
             graph = cameraGraph,
             surfaceToStreamMap = surfaceToStreamMap,
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt
index 642bf5d..e0d3060 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt
@@ -32,7 +32,6 @@
 
 package androidx.camera.camera2.pipe.integration.impl
 
-import android.annotation.SuppressLint
 import android.hardware.camera2.CameraCharacteristics.CONTROL_AE_STATE_FLASH_REQUIRED
 import android.hardware.camera2.CameraDevice
 import android.hardware.camera2.CaptureResult
@@ -616,7 +615,6 @@
                                     completeSignal.complete(null)
                                 }
 
-                                @SuppressLint("ClassVerificationFailure")
                                 override fun onFailed(
                                     requestMetadata: RequestMetadata,
                                     frameNumber: FrameNumber,
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseSurfaceManager.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseSurfaceManager.kt
index aa4e070..9fd040b 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseSurfaceManager.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseSurfaceManager.kt
@@ -36,6 +36,7 @@
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.async
 import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.withTimeoutOrNull
 
 private const val TIMEOUT_GET_SURFACE_IN_MS = 5_000L
@@ -52,12 +53,12 @@
 
     private val lock = Any()
 
+    @GuardedBy("lock") private var setupDeferred: Deferred<Unit>? = null
+
     @GuardedBy("lock") private val activeSurfaceMap = mutableMapOf<Surface, DeferrableSurface>()
 
     @GuardedBy("lock") private var configuredSurfaceMap: Map<Surface, DeferrableSurface>? = null
 
-    private var setupSurfaceDeferred: Deferred<Unit>? = null
-
     @GuardedBy("lock") private var stopDeferred: CompletableDeferred<Unit>? = null
 
     @GuardedBy("lock") private var _sessionConfigAdapter: SessionConfigAdapter? = null
@@ -68,70 +69,100 @@
         sessionConfigAdapter: SessionConfigAdapter,
         surfaceToStreamMap: Map<DeferrableSurface, StreamId>,
         timeoutMillis: Long = TIMEOUT_GET_SURFACE_IN_MS,
-    ): Deferred<Unit> {
-        check(setupSurfaceDeferred == null)
-        check(synchronized(lock) { stopDeferred == null && configuredSurfaceMap == null })
+    ): Deferred<Unit> =
+        synchronized(lock) {
+            check(setupDeferred == null) { "Surfaces should only be set up once!" }
+            check(stopDeferred == null) { "Surfaces being setup after stopped!" }
+            check(configuredSurfaceMap == null)
 
-        return threads.scope
-            .async {
-                check(sessionConfigAdapter.isSessionConfigValid())
-
-                sessionConfigAdapter.useDeferrableSurfaces { deferrableSurfaces ->
-                    val surfaces = getSurfaces(deferrableSurfaces, timeoutMillis)
-                    if (!isActive) return@async
-                    if (surfaces.isEmpty()) {
-                        Log.error { "Surface list is empty" }
-                        return@async
-                    }
-                    if (surfaces.areValid()) {
-                        synchronized(lock) {
-                            configuredSurfaceMap =
-                                deferrableSurfaces.associateBy { deferrableSurface ->
-                                    surfaces[deferrableSurfaces.indexOf(deferrableSurface)]!!
-                                }
-                            _sessionConfigAdapter = sessionConfigAdapter
-                            setSurfaceListener()
-                        }
-
-                        surfaceToStreamMap.forEach {
-                            val stream = it.value
-                            val surface = surfaces[deferrableSurfaces.indexOf(it.key)]
-                            Log.debug { "Configured $surface for $stream" }
-                            graph.setSurface(stream = stream, surface = surface)
-                            inactiveSurfaceCloser.configure(stream, it.key, graph)
-                        }
-                    } else {
-                        // Only handle the first failed Surface since subsequent calls to
-                        // CameraInternal#onUseCaseReset() will handle the other failed Surfaces if
-                        // there are any.
-                        sessionConfigAdapter.reportSurfaceInvalid(
-                            deferrableSurfaces[surfaces.indexOf(null)]
-                        )
-                    }
+            val deferrableSurfaces = sessionConfigAdapter.deferrableSurfaces
+            try {
+                DeferrableSurfaces.incrementAll(deferrableSurfaces)
+            } catch (e: SurfaceClosedException) {
+                Log.error { "Failed to increment DeferrableSurfaces: Surfaces closed" }
+                // Report Surface invalid by launching a coroutine to avoid cyclic Dagger injection.
+                threads.scope.launch {
+                    sessionConfigAdapter.reportSurfaceInvalid(e.deferrableSurface)
                 }
+                return@synchronized CompletableDeferred(Unit)
             }
-            .also { completeDeferred ->
-                setupSurfaceDeferred = completeDeferred
-                completeDeferred.invokeOnCompletion { setupSurfaceDeferred = null }
-            }
-    }
+
+            val deferred =
+                threads.scope
+                    .async {
+                        check(sessionConfigAdapter.isSessionConfigValid())
+
+                        val surfaces =
+                            try {
+                                getSurfaces(deferrableSurfaces, timeoutMillis)
+                            } catch (e: SurfaceClosedException) {
+                                Log.error { "Failed to get Surfaces: Surfaces closed" }
+                                sessionConfigAdapter.reportSurfaceInvalid(e.deferrableSurface)
+                                return@async
+                            }
+                        if (!isActive || surfaces.isEmpty()) {
+                            Log.error {
+                                "Failed to get Surfaces: isActive=$isActive, surfaces=$surfaces"
+                            }
+                            return@async
+                        }
+                        if (surfaces.areValid()) {
+                            synchronized(lock) {
+                                configuredSurfaceMap =
+                                    deferrableSurfaces.associateBy { deferrableSurface ->
+                                        checkNotNull(
+                                            surfaces[deferrableSurfaces.indexOf(deferrableSurface)]
+                                        )
+                                    }
+                                _sessionConfigAdapter = sessionConfigAdapter
+                                setSurfaceListener()
+                            }
+
+                            surfaceToStreamMap.forEach {
+                                val stream = it.value
+                                val surface = surfaces[deferrableSurfaces.indexOf(it.key)]
+                                Log.debug { "Configured $surface for $stream" }
+                                graph.setSurface(stream = stream, surface = surface)
+                                inactiveSurfaceCloser.configure(stream, it.key, graph)
+                            }
+                            Log.info { "Surface setup complete" }
+                        } else {
+                            Log.error { "Surface setup failed: Some Surfaces are invalid" }
+                            // Only handle the first failed Surface since subsequent calls to
+                            // CameraInternal#onUseCaseReset() will handle the other failed Surfaces
+                            // if there are any.
+                            sessionConfigAdapter.reportSurfaceInvalid(
+                                deferrableSurfaces[surfaces.indexOf(null)]
+                            )
+                        }
+                    }
+                    .apply {
+                        // When setup is done or cancelled, decrement the DeferrableSurfaces.
+                        invokeOnCompletion { DeferrableSurfaces.decrementAll(deferrableSurfaces) }
+                    }
+            setupDeferred = deferred
+            return@synchronized deferred
+        }
 
     /** Cancel the Surface set up and stop the monitoring of Surface usage. */
-    public fun stopAsync(): Deferred<Unit> {
-        setupSurfaceDeferred?.cancel()
-
-        return synchronized(lock) {
-                inactiveSurfaceCloser.closeAll()
-                configuredSurfaceMap = null
-                stopDeferred =
-                    stopDeferred
-                        ?: CompletableDeferred<Unit>().apply {
-                            invokeOnCompletion { synchronized(lock) { stopDeferred = null } }
-                        }
-                stopDeferred!!
+    public fun stopAsync(): Deferred<Unit> =
+        synchronized(lock) {
+            val currentStopDeferred = stopDeferred
+            if (currentStopDeferred != null) {
+                Log.warn { "UseCaseSurfaceManager is already stopping!" }
+                return@synchronized currentStopDeferred
             }
-            .also { tryClearSurfaceListener() }
-    }
+            setupDeferred?.cancel()
+            inactiveSurfaceCloser.closeAll()
+            configuredSurfaceMap = null
+
+            val deferred = CompletableDeferred<Unit>()
+            this.stopDeferred = deferred
+            // This may complete stopDeferred immediately
+            tryClearSurfaceListener()
+
+            return@synchronized deferred
+        }
 
     override fun onSurfaceActive(surface: Surface) {
         synchronized(lock) {
@@ -165,10 +196,12 @@
         }
     }
 
+    @GuardedBy("lock")
     private fun setSurfaceListener() {
         cameraPipe.cameraSurfaceManager().addListener(this)
     }
 
+    @GuardedBy("lock")
     private fun tryClearSurfaceListener() {
         synchronized(lock) {
             if (activeSurfaceMap.isEmpty() && configuredSurfaceMap == null) {
@@ -193,26 +226,6 @@
             .orEmpty()
     }
 
-    /**
-     * Set use count at the [DeferrableSurface]s when the specified function [block] is using the
-     * [DeferrableSurface].
-     *
-     * If it cannot set the use count to the [DeferrableSurface], the [block] will not be called.
-     */
-    private inline fun SessionConfigAdapter.useDeferrableSurfaces(
-        block: (List<DeferrableSurface>) -> Unit
-    ) =
-        try {
-            DeferrableSurfaces.incrementAll(deferrableSurfaces)
-            try {
-                block(deferrableSurfaces)
-            } finally {
-                DeferrableSurfaces.decrementAll(deferrableSurfaces)
-            }
-        } catch (e: SurfaceClosedException) {
-            reportSurfaceInvalid(e.deferrableSurface)
-        }
-
     private fun List<Surface?>.areValid(): Boolean {
         // If a Surface in configuredSurfaces is null it means the
         // Surface was not retrieved from the ListenableFuture.
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
index e82afc7..c98a67a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
@@ -110,6 +110,8 @@
         CLOSED
     }
 
+    private val sessionDisconnected = CountDownLatch(1)
+
     @GuardedBy("lock") private var hasAttemptedCaptureSession = false
     private val captureSessionAttemptCompleted = CountDownLatch(1)
 
@@ -193,6 +195,13 @@
         Log.debug { "$this session disconnecting" }
         Debug.traceStart { "$this#onSessionDisconnected" }
         disconnect()
+        // Important: CaptureSessionState can be disconnected on a separate path, and we may lose
+        // the race if another disconnect call was invoked in parallel. When that happens, we get
+        // an early return, but the winning disconnect call may still be in the process of
+        // disconnecting - notably doing stopRepeating() and abortCaptures(). We wait here such
+        // that we don't prematurely create a new capture session, which would close the capture
+        // session that is still being disconnected. See b/383434693 for details.
+        Debug.trace("$this#onSessionDisconnected Await") { sessionDisconnected.await() }
         Debug.traceStop()
     }
 
@@ -281,7 +290,7 @@
 
         synchronized(lock) {
             if (state == State.CLOSING || state == State.CLOSED) {
-                return@synchronized
+                return
             }
             state = State.CLOSING
 
@@ -396,6 +405,9 @@
             graphListener.onGraphStopped(null)
             Debug.traceStop()
         }
+
+        /** Do not remove - see [onSessionDisconnected] for details. */
+        sessionDisconnected.countDown()
     }
 
     /**
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
index da4c2fd..2b9b8a2 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
@@ -202,7 +202,7 @@
         mZoomControl = new ZoomControl(this, mCameraCharacteristics, mExecutor);
         mTorchControl = new TorchControl(this, mCameraCharacteristics, mExecutor);
         if (Build.VERSION.SDK_INT >= 23) {
-            mZslControl = new ZslControlImpl(mCameraCharacteristics);
+            mZslControl = new ZslControlImpl(mCameraCharacteristics, mExecutor);
         } else {
             mZslControl = new ZslControlNoOpImpl();
         }
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
index 8a04dc8..d000354 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
@@ -360,15 +360,20 @@
                 if (captureConfig.getTemplateType() == CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
                         && !mCameraControl.getZslControl().isZslDisabledByFlashMode()
                         && !mCameraControl.getZslControl().isZslDisabledByUserCaseConfig()) {
-                    ImageProxy imageProxy =
-                            mCameraControl.getZslControl().dequeueImageFromBuffer();
-                    boolean isSuccess = imageProxy != null
-                            && mCameraControl.getZslControl().enqueueImageToImageWriter(
-                                        imageProxy);
-                    if (isSuccess) {
-                        cameraCaptureResult =
-                                CameraCaptureResults.retrieveCameraCaptureResult(
-                                        imageProxy.getImageInfo());
+                    ImageProxy imageProxy = mCameraControl.getZslControl().dequeueImageFromBuffer();
+                    if (imageProxy != null) {
+                        if (mCameraControl.getZslControl().enqueueImageToImageWriter(imageProxy)) {
+                            cameraCaptureResult = CameraCaptureResults.retrieveCameraCaptureResult(
+                                    imageProxy.getImageInfo());
+                        } else {
+                            Logger.e(TAG, "Failed to enqueue image to image writer");
+                        }
+
+                        if (cameraCaptureResult == null) {
+                            imageProxy.close();
+                        }
+                    } else {
+                        Logger.d(TAG, "ZSL capture skipped due to no valid buffer image");
                     }
                 }
 
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ZslControlImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ZslControlImpl.java
index 4c834e8..f0921e4 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ZslControlImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ZslControlImpl.java
@@ -60,6 +60,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.NoSuchElementException;
+import java.util.concurrent.Executor;
 
 /**
  * Implementation for {@link ZslControl}.
@@ -76,6 +77,7 @@
     static final int MAX_IMAGES = RING_BUFFER_CAPACITY * 3;
 
     private final @NonNull CameraCharacteristicsCompat mCameraCharacteristicsCompat;
+    private final @NonNull Executor mExecutor;
 
     @VisibleForTesting
     @SuppressWarnings("WeakerAccess")
@@ -94,8 +96,10 @@
 
     @Nullable ImageWriter mReprocessingImageWriter;
 
-    ZslControlImpl(@NonNull CameraCharacteristicsCompat cameraCharacteristicsCompat) {
+    ZslControlImpl(@NonNull CameraCharacteristicsCompat cameraCharacteristicsCompat,
+            @NonNull Executor executor) {
         mCameraCharacteristicsCompat = cameraCharacteristicsCompat;
+        mExecutor = executor;
         mIsPrivateReprocessingSupported =
                 isCapabilitySupported(mCameraCharacteristicsCompat,
                         REQUEST_AVAILABLE_CAPABILITIES_PRIVATE_REPROCESSING);
@@ -104,7 +108,7 @@
 
         mImageRingBuffer = new ZslRingBuffer(
                 RING_BUFFER_CAPACITY,
-                imageProxy -> imageProxy.close());
+                ImageProxy::close);
     }
 
     @Override
@@ -240,6 +244,13 @@
         if (Build.VERSION.SDK_INT >= 23 && mReprocessingImageWriter != null && image != null) {
             try {
                 ImageWriterCompat.queueInputImage(mReprocessingImageWriter, image);
+                // It's essential to call ImageProxy#close().
+                // Add an OnImageReleasedListener to ensure the ImageProxy is closed after
+                // the image is written to the output surface. This is crucial to prevent
+                // resource leaks, where images might not be closed properly if CameraX fails
+                // to propagate close events to its internal components.
+                ImageWriterCompat.setOnImageReleasedListener(mReprocessingImageWriter,
+                        writer -> imageProxy.close(), mExecutor);
             } catch (IllegalStateException e) {
                 Logger.e(TAG, "enqueueImageToImageWriter throws IllegalStateException = "
                         + e.getMessage());
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
index 96895c8..a9e2c9d 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
@@ -1438,7 +1438,8 @@
                     hasCapabilities = true,
                     isYuvReprocessingSupported = true,
                     isPrivateReprocessingSupported = true
-                )
+                ),
+                executorService
             )
 
         // Only need to initialize when not disabled
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/ZslControlImplTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/ZslControlImplTest.kt
index 8e63102..71b15b5 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/ZslControlImplTest.kt
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/ZslControlImplTest.kt
@@ -30,6 +30,7 @@
 import androidx.camera.camera2.internal.ZslControlImpl.RING_BUFFER_CAPACITY
 import androidx.camera.camera2.internal.compat.CameraCharacteristicsCompat
 import androidx.camera.core.impl.SessionConfig
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import com.google.common.truth.Truth.assertThat
 import org.junit.Before
 import org.junit.Test
@@ -73,7 +74,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -98,7 +100,8 @@
                     isYuvReprocessingSupported = true,
                     isPrivateReprocessingSupported = false,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -116,7 +119,8 @@
                     isYuvReprocessingSupported = true,
                     isPrivateReprocessingSupported = false,
                     isJpegValidOutputFormat = false
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -134,7 +138,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = false,
                     isJpegValidOutputFormat = false
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -152,7 +157,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
         zslControl.isZslDisabledByUserCaseConfig = true
 
@@ -171,7 +177,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
         zslControl.isZslDisabledByFlashMode = true
 
@@ -197,7 +204,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -220,7 +228,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
@@ -241,7 +250,8 @@
                     isYuvReprocessingSupported = false,
                     isPrivateReprocessingSupported = true,
                     isJpegValidOutputFormat = true
-                )
+                ),
+                CameraXExecutors.mainThreadExecutor()
             )
 
         zslControl.addZslConfig(sessionConfigBuilder)
diff --git a/camera/camera-core/api/current.txt b/camera/camera-core/api/current.txt
index f50d9d5..1044082 100644
--- a/camera/camera-core/api/current.txt
+++ b/camera/camera-core/api/current.txt
@@ -291,6 +291,7 @@
     field public static final int COORDINATE_SYSTEM_ORIGINAL = 0; // 0x0
     field public static final int COORDINATE_SYSTEM_SENSOR = 2; // 0x2
     field public static final int COORDINATE_SYSTEM_VIEW_REFERENCED = 1; // 0x1
+    field public static final int OUTPUT_IMAGE_FORMAT_NV21 = 3; // 0x3
     field public static final int OUTPUT_IMAGE_FORMAT_RGBA_8888 = 2; // 0x2
     field public static final int OUTPUT_IMAGE_FORMAT_YUV_420_888 = 1; // 0x1
     field public static final int STRATEGY_BLOCK_PRODUCER = 1; // 0x1
diff --git a/camera/camera-core/api/restricted_current.txt b/camera/camera-core/api/restricted_current.txt
index f50d9d5..1044082 100644
--- a/camera/camera-core/api/restricted_current.txt
+++ b/camera/camera-core/api/restricted_current.txt
@@ -291,6 +291,7 @@
     field public static final int COORDINATE_SYSTEM_ORIGINAL = 0; // 0x0
     field public static final int COORDINATE_SYSTEM_SENSOR = 2; // 0x2
     field public static final int COORDINATE_SYSTEM_VIEW_REFERENCED = 1; // 0x1
+    field public static final int OUTPUT_IMAGE_FORMAT_NV21 = 3; // 0x3
     field public static final int OUTPUT_IMAGE_FORMAT_RGBA_8888 = 2; // 0x2
     field public static final int OUTPUT_IMAGE_FORMAT_YUV_420_888 = 1; // 0x1
     field public static final int STRATEGY_BLOCK_PRODUCER = 1; // 0x1
diff --git a/camera/camera-core/build.gradle b/camera/camera-core/build.gradle
index 7cccaf2..191b670 100644
--- a/camera/camera-core/build.gradle
+++ b/camera/camera-core/build.gradle
@@ -44,7 +44,7 @@
     implementation("androidx.lifecycle:lifecycle-common:2.1.0")
     implementation("androidx.tracing:tracing:1.2.0")
     implementation(libs.autoValueAnnotations)
-    androidTestImplementation project(":camera:camera-camera2")
+    androidTestImplementation(project(":camera:camera-camera2"))
     compileOnly(project(":external:libyuv"))
 
     annotationProcessor(libs.autoValue)
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageAnalysisAbstractAnalyzerTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageAnalysisAbstractAnalyzerTest.java
index c91255c..0825137 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageAnalysisAbstractAnalyzerTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageAnalysisAbstractAnalyzerTest.java
@@ -19,6 +19,7 @@
 import static android.graphics.ImageFormat.YUV_420_888;
 import static android.graphics.PixelFormat.RGBA_8888;
 
+import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21;
 import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888;
 import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888;
 import static androidx.camera.core.ImageAnalysisAbstractAnalyzer.getAdditionalTransformMatrixAppliedByProcessor;
@@ -149,58 +150,36 @@
     }
 
     @Test
-    public void analysisRunWhenOutputImageYUV() throws ExecutionException, InterruptedException {
-        // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_YUV_420_888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mYUVImageReaderProxy);
-
-        // Act.
-        ListenableFuture<Void> result =
-                mImageAnalysisAbstractAnalyzer.analyzeImage(mImageProxy);
-        result.get();
-
-        // Assert.
-        ArgumentCaptor<ImageProxy> imageProxyArgumentCaptor =
-                ArgumentCaptor.forClass(ImageProxy.class);
-        verify(mAnalyzer).analyze(imageProxyArgumentCaptor.capture());
-        assertThat(imageProxyArgumentCaptor.getValue().getFormat()).isEqualTo(YUV_420_888);
-        assertThat(imageProxyArgumentCaptor.getValue().getPlanes().length).isEqualTo(3);
-        assertThat(imageProxyArgumentCaptor.getValue().getCropRect()).isEqualTo(
-                new Rect(0, 0, WIDTH, HEIGHT));
+    public void analysisRunWhenOutputImage_YUV_420_888()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenOutputImage(OUTPUT_IMAGE_FORMAT_YUV_420_888, mYUVImageReaderProxy, 3,
+                YUV_420_888);
     }
 
     @Test
-    public void analysisRunWhenOutputImageRGB() throws ExecutionException, InterruptedException {
-        // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRGBImageReaderProxy);
-
-        // Act.
-        ListenableFuture<Void> result =
-                mImageAnalysisAbstractAnalyzer.analyzeImage(mImageProxy);
-        result.get();
-
-        // Assert.
-        ArgumentCaptor<ImageProxy> imageProxyArgumentCaptor =
-                ArgumentCaptor.forClass(ImageProxy.class);
-        verify(mAnalyzer).analyze(imageProxyArgumentCaptor.capture());
-        assertThat(imageProxyArgumentCaptor.getValue().getFormat()).isEqualTo(RGBA_8888);
-        assertThat(imageProxyArgumentCaptor.getValue().getPlanes().length).isEqualTo(1);
-        assertThat(imageProxyArgumentCaptor.getValue().getCropRect()).isEqualTo(
-                new Rect(0, 0, WIDTH, HEIGHT));
+    public void analysisRunWhenOutputImage_RGBA_8888()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenOutputImage(OUTPUT_IMAGE_FORMAT_RGBA_8888, mRGBImageReaderProxy, 1,
+                RGBA_8888);
     }
 
-    @SdkSuppress(minSdkVersion = 23)
     @Test
-    public void analysisRunWhenRotateYUVMinSdk23() throws ExecutionException, InterruptedException {
-        // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_YUV_420_888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedYUVImageReaderProxy);
-        mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(true);
-        mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
-        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/90);
+    public void analysisRunWhenOutputImage_NV21()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenOutputImage(OUTPUT_IMAGE_FORMAT_NV21, null, 3, YUV_420_888);
+    }
 
-        Matrix original = new Matrix(mSensorToBufferMatrix);
+    private void analysisRunWhenOutputImage(
+            int outputFormat,
+            @Nullable SafeCloseImageReaderProxy processedImageReaderProxy,
+            int expectedPlaneCount,
+            int expectedFormat)
+            throws ExecutionException, InterruptedException {
+        // Arrange.
+        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(outputFormat);
+        if (processedImageReaderProxy != null) {
+            mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(processedImageReaderProxy);
+        }
 
         // Act.
         ListenableFuture<Void> result =
@@ -211,17 +190,15 @@
         ArgumentCaptor<ImageProxy> imageProxyArgumentCaptor =
                 ArgumentCaptor.forClass(ImageProxy.class);
         verify(mAnalyzer).analyze(imageProxyArgumentCaptor.capture());
-
-        Matrix target = new Matrix();
-        target.setConcat(original, getAdditionalTransformMatrixAppliedByProcessor(
-                WIDTH, HEIGHT, HEIGHT, WIDTH, 90));
-        assertThat(imageProxyArgumentCaptor.getValue().getImageInfo()
-                .getSensorToBufferTransformMatrix()).isEqualTo(target);
-
-        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNotNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNotNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNotNull();
+        assertThat(imageProxyArgumentCaptor.getValue().getFormat()).isEqualTo(expectedFormat);
+        assertThat(imageProxyArgumentCaptor.getValue().getPlanes().length).isEqualTo(
+                expectedPlaneCount);
+        assertThat(imageProxyArgumentCaptor.getValue().getCropRect()).isEqualTo(
+                new Rect(0, 0, WIDTH, HEIGHT));
+        if (outputFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+            assertThat(ImageProcessingUtil.isNV21FormatImage(
+                    imageProxyArgumentCaptor.getValue())).isTrue();
+        }
     }
 
     @SdkSuppress(maxSdkVersion = 22, minSdkVersion = 21)
@@ -253,14 +230,56 @@
         assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNotNull();
         assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNotNull();
         assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 23)
+    @Test
+    public void analysisRunWhenRotateYUVMinSdk23() throws ExecutionException, InterruptedException {
+        analysisRunWhenRotate(OUTPUT_IMAGE_FORMAT_YUV_420_888, mRotatedYUVImageReaderProxy);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNull();
     }
 
     @Test
-    public void analysisRunWhenRotateRGB() throws ExecutionException,
-            InterruptedException {
+    public void analysisRunWhenRotate_RGBA_8888() throws ExecutionException, InterruptedException {
+        analysisRunWhenRotate(OUTPUT_IMAGE_FORMAT_RGBA_8888, mRotatedRGBImageReaderProxy);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNull();
+    }
+
+    @Test
+    public void analysisRunWhenRotate_NV21() throws ExecutionException, InterruptedException {
+        analysisRunWhenRotate(OUTPUT_IMAGE_FORMAT_NV21, null);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNotNull();
+    }
+
+    private void analysisRunWhenRotate(int outputFormat,
+            @Nullable SafeCloseImageReaderProxy processedImageReaderProxy)
+            throws ExecutionException, InterruptedException {
         // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedRGBImageReaderProxy);
+        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(outputFormat);
+        if (processedImageReaderProxy != null) {
+            mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(
+                    processedImageReaderProxy);
+        }
         mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(true);
         mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
         mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/90);
@@ -284,20 +303,41 @@
                 .getSensorToBufferTransformMatrix()).isEqualTo(target);
         assertThat(imageProxyArgumentCaptor.getValue().getCropRect())
                 .isEqualTo(new Rect(0, 0, HEIGHT, WIDTH));
-
-        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNotNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
+        if (outputFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+            assertThat(ImageProcessingUtil.isNV21FormatImage(
+                    imageProxyArgumentCaptor.getValue())).isTrue();
+        }
     }
 
     @SdkSuppress(minSdkVersion = 23)
     @Test
-    public void analysisRunWhenYUVSetTargetRotationMultipleTimes() throws ExecutionException,
-            InterruptedException {
+    public void analysisRunWhenSetTargetRotationMultipleTimes_YUV_420_888()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenSetTargetRotationMultipleTimes(OUTPUT_IMAGE_FORMAT_YUV_420_888,
+                mRotatedYUVImageReaderProxy);
+    }
+
+    @Test
+    public void analysisRunWhenSetTargetRotationMultipleTimes_RGBA_8888()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenSetTargetRotationMultipleTimes(OUTPUT_IMAGE_FORMAT_RGBA_8888,
+                mRotatedRGBImageReaderProxy);
+    }
+
+    @Test
+    public void analysisRunWhenSetTargetRotationMultipleTimes_NV21()
+            throws ExecutionException, InterruptedException {
+        analysisRunWhenSetTargetRotationMultipleTimes(OUTPUT_IMAGE_FORMAT_NV21, null);
+    }
+
+    private void analysisRunWhenSetTargetRotationMultipleTimes(int outputFormat,
+            @Nullable SafeCloseImageReaderProxy processedImageReaderProxy)
+            throws ExecutionException, InterruptedException {
         // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_YUV_420_888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedYUVImageReaderProxy);
+        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(outputFormat);
+        if (processedImageReaderProxy != null) {
+            mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(processedImageReaderProxy);
+        }
         mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(true);
         mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
         mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/90);
@@ -328,86 +368,78 @@
                 WIDTH, HEIGHT, WIDTH, HEIGHT, 180));
         assertThat(imageProxyArgumentCaptor.getValue().getImageInfo()
                 .getSensorToBufferTransformMatrix()).isEqualTo(target);
+        if (outputFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+            assertThat(ImageProcessingUtil.isNV21FormatImage(
+                    imageProxyArgumentCaptor.getValue())).isTrue();
+        }
     }
 
     @Test
-    public void analysisRunWhenRGBSetTargetRotationMultipleTimes() throws ExecutionException,
+    public void analysisRunWhenNoRotate_RGBA_8888() throws ExecutionException,
             InterruptedException {
+        analysisRunWhenNoRotateYUV(OUTPUT_IMAGE_FORMAT_RGBA_8888, mRotatedRGBImageReaderProxy);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNull();
+    }
+
+    @Test
+    public void analysisRunWhenNoRotate_YUV_420_888() throws ExecutionException,
+            InterruptedException {
+        analysisRunWhenNoRotateYUV(OUTPUT_IMAGE_FORMAT_YUV_420_888, mRotatedRGBImageReaderProxy);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNull();
+    }
+
+    @Test
+    public void analysisRunWhenNoRotate_NV21() throws ExecutionException,
+            InterruptedException {
+        analysisRunWhenNoRotateYUV(OUTPUT_IMAGE_FORMAT_NV21, null);
+
+        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21YDelegatedBuffer()).isNotNull();
+        assertThat(mImageAnalysisAbstractAnalyzer.getNV21UVDelegatedBuffer()).isNotNull();
+    }
+
+    private void analysisRunWhenNoRotateYUV(int outputFormat,
+            @Nullable SafeCloseImageReaderProxy processedImageReaderProxy)
+            throws ExecutionException, InterruptedException {
         // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedRGBImageReaderProxy);
-        mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(true);
+        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(outputFormat);
+        if (processedImageReaderProxy != null) {
+            mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(
+                    mRotatedRGBImageReaderProxy);
+        }
+        mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(false);
         mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
-        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/90);
-
-        Matrix original = new Matrix(mSensorToBufferMatrix);
+        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/270);
 
         // Act.
-        ListenableFuture<Void> result1 =
+        ListenableFuture<Void> result =
                 mImageAnalysisAbstractAnalyzer.analyzeImage(mImageProxy);
-        result1.get();
-
-        reset(mAnalyzer);
-
-        // Act.
-        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/180);
-        ListenableFuture<Void> result2 =
-                mImageAnalysisAbstractAnalyzer.analyzeImage(mSecondImageProxy);
-        result2.get();
+        result.get();
 
         // Assert.
         ArgumentCaptor<ImageProxy> imageProxyArgumentCaptor =
                 ArgumentCaptor.forClass(ImageProxy.class);
         verify(mAnalyzer).analyze(imageProxyArgumentCaptor.capture());
 
-        // Verify that additional transform matrix will only be applied to original matrix once.
-        Matrix target = new Matrix();
-        target.setConcat(original, getAdditionalTransformMatrixAppliedByProcessor(
-                WIDTH, HEIGHT, WIDTH, HEIGHT, 180));
-        assertThat(imageProxyArgumentCaptor.getValue().getImageInfo()
-                .getSensorToBufferTransformMatrix()).isEqualTo(target);
-    }
-
-    @Test
-    public void analysisRunWhenNoRotateRGB() throws ExecutionException,
-            InterruptedException {
-        // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedRGBImageReaderProxy);
-        mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(false);
-        mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
-        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/270);
-
-        // Act.
-        ListenableFuture<Void> result =
-                mImageAnalysisAbstractAnalyzer.analyzeImage(mImageProxy);
-        result.get();
-
-        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
-    }
-
-    @Test
-    public void analysisRunWhenNoRotateYUV() throws ExecutionException,
-            InterruptedException {
-        // Arrange.
-        mImageAnalysisAbstractAnalyzer.setOutputImageFormat(OUTPUT_IMAGE_FORMAT_YUV_420_888);
-        mImageAnalysisAbstractAnalyzer.setProcessedImageReaderProxy(mRotatedRGBImageReaderProxy);
-        mImageAnalysisAbstractAnalyzer.setOutputImageRotationEnabled(false);
-        mImageAnalysisAbstractAnalyzer.setSensorToBufferTransformMatrix(mSensorToBufferMatrix);
-        mImageAnalysisAbstractAnalyzer.setRelativeRotation(/*rotation=*/270);
-
-        // Act.
-        ListenableFuture<Void> result =
-                mImageAnalysisAbstractAnalyzer.analyzeImage(mImageProxy);
-        result.get();
-
-        assertThat(mImageAnalysisAbstractAnalyzer.getRGBConverterBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getYRotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getURotatedBuffer()).isNull();
-        assertThat(mImageAnalysisAbstractAnalyzer.getVRotatedBuffer()).isNull();
+        if (outputFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+            assertThat(ImageProcessingUtil.isNV21FormatImage(
+                    imageProxyArgumentCaptor.getValue())).isTrue();
+        }
     }
 
     @Test
@@ -561,5 +593,13 @@
         @Nullable ByteBuffer getVRotatedBuffer() {
             return mImageAnalysisNonBlockingAnalyzer.mVRotatedBuffer;
         }
+
+        @Nullable ByteBuffer getNV21YDelegatedBuffer() {
+            return mImageAnalysisNonBlockingAnalyzer.mNV21YDelegatedBuffer;
+        }
+
+        @Nullable ByteBuffer getNV21UVDelegatedBuffer() {
+            return mImageAnalysisNonBlockingAnalyzer.mNV21UVDelegatedBuffer;
+        }
     }
 }
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageProcessingUtilTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageProcessingUtilTest.java
index c5f565d1..89ed840 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageProcessingUtilTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageProcessingUtilTest.java
@@ -16,14 +16,20 @@
 
 package androidx.camera.core;
 
+import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21;
+import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888;
 import static androidx.camera.core.ImageProcessingUtil.convertJpegBytesToImage;
 import static androidx.camera.core.ImageProcessingUtil.rotateYUV;
+import static androidx.camera.core.ImageProcessingUtil.rotateYUVAndConvertToNV21;
 import static androidx.camera.core.ImageProcessingUtil.writeJpegBytesToSurface;
+import static androidx.camera.testing.impl.IgnoreProblematicDeviceRule.Companion;
 import static androidx.camera.testing.impl.ImageProxyUtil.createYUV420ImagePlanes;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static org.junit.Assume.assumeFalse;
+
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Canvas;
@@ -31,9 +37,12 @@
 import android.graphics.ImageFormat;
 import android.graphics.PixelFormat;
 import android.media.ImageWriter;
+import android.os.Build;
 
 import androidx.annotation.IntRange;
 import androidx.camera.core.impl.utils.Exif;
+import androidx.camera.core.internal.utils.ImageUtil;
+import androidx.camera.testing.impl.TestImageUtil;
 import androidx.camera.testing.impl.fakes.FakeImageInfo;
 import androidx.camera.testing.impl.fakes.FakeImageProxy;
 import androidx.core.math.MathUtils;
@@ -76,6 +85,8 @@
     private ByteBuffer mYRotatedBuffer;
     private ByteBuffer mURotatedBuffer;
     private ByteBuffer mVRotatedBuffer;
+    private ByteBuffer mNV21YDelegatedByteBuffer;
+    private ByteBuffer mNV21UVDelegatedByteBuffer;
     private static final int[] YUV_WHITE_STUDIO_SWING_BT601 = {/*y=*/235, /*u=*/128, /*v=*/128};
     private static final int[] YUV_BLACK_STUDIO_SWING_BT601 = {/*y=*/16, /*u=*/128, /*v=*/128};
     private static final int[] YUV_BLUE_STUDIO_SWING_BT601 = {/*y=*/16, /*u=*/240, /*v=*/128};
@@ -89,51 +100,65 @@
 
     @Before
     public void setUp() {
-        mYUVImageProxy = new FakeImageProxy(new FakeImageInfo());
-        mYUVImageProxy.setWidth(WIDTH);
-        mYUVImageProxy.setHeight(HEIGHT);
+        createTestResources(WIDTH, HEIGHT, 90);
+    }
+
+    @After
+    public void tearDown() {
+        closeTestResources();
+    }
+
+    private void createTestResources(int width, int height, int rotation) {
+        mYUVImageProxy = TestImageUtil.createYuvFakeImageProxy(new FakeImageInfo(), width, height,
+                true, true);
+        mYUVImageProxy.setWidth(width);
+        mYUVImageProxy.setHeight(height);
         mYUVImageProxy.setFormat(ImageFormat.YUV_420_888);
 
+        boolean flipWh = rotation % 180 != 0;
+
         // rgb image reader proxy should not be mocked for JNI native code
         mRGBImageReaderProxy = new SafeCloseImageReaderProxy(
                 ImageReaderProxys.createIsolatedReader(
-                        WIDTH,
-                        HEIGHT,
+                        width,
+                        height,
                         PixelFormat.RGBA_8888,
                         MAX_IMAGES));
 
         // rotated image reader proxy with width and height flipped
         mRotatedRGBImageReaderProxy = new SafeCloseImageReaderProxy(
                 ImageReaderProxys.createIsolatedReader(
-                        HEIGHT,
-                        WIDTH,
+                        flipWh ? height : width,
+                        flipWh ? width : height,
                         PixelFormat.RGBA_8888,
                         MAX_IMAGES));
 
         mRotatedYUVImageReaderProxy = new SafeCloseImageReaderProxy(
                 ImageReaderProxys.createIsolatedReader(
-                        HEIGHT,
-                        WIDTH,
+                        flipWh ? height : width,
+                        flipWh ? width : height,
                         ImageFormat.YUV_420_888,
                         MAX_IMAGES));
 
         mJpegImageReaderProxy = new SafeCloseImageReaderProxy(
                 ImageReaderProxys.createIsolatedReader(
-                        WIDTH,
-                        HEIGHT,
+                        flipWh ? height : width,
+                        flipWh ? width : height,
                         ImageFormat.JPEG,
                         MAX_IMAGES));
 
-        mRgbConvertedBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT * 4);
-        mYRotatedBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT);
-        mURotatedBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 2);
-        mVRotatedBuffer = ByteBuffer.allocateDirect(WIDTH * HEIGHT / 2);
+        mRgbConvertedBuffer = ByteBuffer.allocateDirect(width * height * 4);
+        mYRotatedBuffer = ByteBuffer.allocateDirect(width * height);
+        mURotatedBuffer = ByteBuffer.allocateDirect(width * height / 2);
+        mVRotatedBuffer = ByteBuffer.allocateDirect(width * height / 2);
+        mNV21YDelegatedByteBuffer = ByteBuffer.allocateDirect(width * height);
+        mNV21UVDelegatedByteBuffer = ByteBuffer.allocateDirect(width * height / 2);
     }
 
-    @After
-    public void tearDown() {
+    private void closeTestResources() {
         mRGBImageReaderProxy.safeClose();
         mRotatedRGBImageReaderProxy.safeClose();
+        mRotatedYUVImageReaderProxy.safeClose();
         mJpegImageReaderProxy.safeClose();
     }
 
@@ -388,34 +413,106 @@
 
     @SdkSuppress(minSdkVersion = 23)
     @Test
-    public void rotateYUV_imageRotated() {
+    public void rotateYUV_imageRotated_0() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_YUV_420_888, 0, true);
+    }
+
+    @SdkSuppress(minSdkVersion = 23)
+    @Test
+    public void rotateYUV_imageRotated_90() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_YUV_420_888, 90, false);
+    }
+
+    @SdkSuppress(minSdkVersion = 23)
+    @Test
+    public void rotateYUV_imageRotated_180() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_YUV_420_888, 180, false);
+    }
+
+    @SdkSuppress(minSdkVersion = 23)
+    @Test
+    public void rotateYUV_imageRotated_270() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_YUV_420_888, 270, false);
+    }
+
+    @Test
+    public void rotateYUV_imageRotated_0_outputNV21() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_NV21, 0, false);
+    }
+
+    @Test
+    public void rotateYUV_imageRotated_90_outputNV21() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_NV21, 90, false);
+    }
+
+    @Test
+    public void rotateYUV_imageRotated_180_outputNV21() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_NV21, 180, false);
+    }
+
+    @Test
+    public void rotateYUV_imageRotated_270_outputNV21() {
+        rotateYUV_imageRotated(OUTPUT_IMAGE_FORMAT_NV21, 270, false);
+    }
+
+    private void rotateYUV_imageRotated(
+            int outputImageFormat,
+            int rotation,
+            boolean outputShouldBeNull) {
+        // Pixel2 API28 emulator has problem to run the test
+        assumeFalse(Companion.isPixel2Api28Emulator());
         // Arrange.
-        mYUVImageProxy.setPlanes(createYUV420ImagePlanes(
-                WIDTH,
-                HEIGHT,
-                PIXEL_STRIDE_Y,
-                PIXEL_STRIDE_UV,
-                /*flipUV=*/true,
-                /*incrementValue=*/false));
+        int width = 640;
+        int height = 480;
+        closeTestResources();
+        createTestResources(width, height, rotation);
 
         // Act.
-        ImageProxy yuvImageProxy = rotateYUV(
-                mYUVImageProxy,
-                mRotatedYUVImageReaderProxy,
-                ImageWriter.newInstance(
-                        mRotatedYUVImageReaderProxy.getSurface(),
-                        mRotatedYUVImageReaderProxy.getMaxImages()),
-                mYRotatedBuffer,
-                mURotatedBuffer,
-                mVRotatedBuffer,
-                /*rotation=*/90);
+        ImageProxy yuvImageProxy = null;
+
+        if (outputImageFormat == OUTPUT_IMAGE_FORMAT_YUV_420_888 && Build.VERSION.SDK_INT >= 23) {
+            yuvImageProxy = rotateYUV(
+                    mYUVImageProxy,
+                    mRotatedYUVImageReaderProxy,
+                    ImageWriter.newInstance(
+                            mRotatedYUVImageReaderProxy.getSurface(),
+                            mRotatedYUVImageReaderProxy.getMaxImages()),
+                    mYRotatedBuffer,
+                    mURotatedBuffer,
+                    mVRotatedBuffer,
+                    /*rotation=*/rotation);
+        } else if (outputImageFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+            yuvImageProxy = rotateYUVAndConvertToNV21(
+                    mYUVImageProxy,
+                    mYRotatedBuffer,
+                    mURotatedBuffer,
+                    mVRotatedBuffer,
+                    mNV21YDelegatedByteBuffer,
+                    mNV21UVDelegatedByteBuffer,
+                    /*rotation=*/rotation);
+        }
 
         // Assert.
+        if (outputShouldBeNull) {
+            assertThat(yuvImageProxy).isNull();
+            return;
+        }
+
+        boolean flipWh = rotation % 180 != 0;
         assertThat(yuvImageProxy).isNotNull();
         assertThat(yuvImageProxy.getFormat()).isEqualTo(ImageFormat.YUV_420_888);
         assertThat(yuvImageProxy.getPlanes().length).isEqualTo(3);
-        assertThat(yuvImageProxy.getWidth()).isEqualTo(HEIGHT);
-        assertThat(yuvImageProxy.getHeight()).isEqualTo(WIDTH);
+        assertThat(yuvImageProxy.getWidth()).isEqualTo(flipWh ? height : width);
+        assertThat(yuvImageProxy.getHeight()).isEqualTo(flipWh ? width : height);
+
+        // Verifies the color value diff between the rotated input image and the decoded output
+        // image proxy.
+        Bitmap inputDecodedBitmap = ImageUtil.createBitmapFromImageProxy(mYUVImageProxy);
+        Bitmap inputRotatedBitmap = TestImageUtil.rotateBitmap(inputDecodedBitmap, rotation);
+        Bitmap outputBitmap = ImageUtil.createBitmapFromImageProxy(yuvImageProxy);
+        assertThat(TestImageUtil.getAverageDiff(inputRotatedBitmap, outputBitmap))
+                .isEqualTo(0);
+
         yuvImageProxy.close();
     }
 
diff --git a/camera/camera-core/src/main/cpp/image_processing_util_jni.cc b/camera/camera-core/src/main/cpp/image_processing_util_jni.cc
index b95be97..66d9c5b 100644
--- a/camera/camera-core/src/main/cpp/image_processing_util_jni.cc
+++ b/camera/camera-core/src/main/cpp/image_processing_util_jni.cc
@@ -28,6 +28,7 @@
 #include "libyuv/convert_argb.h"
 #include "libyuv/rotate_argb.h"
 #include "libyuv/convert.h"
+#include "libyuv/planar_functions.h"
 
 #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "YuvToRgbJni", __VA_ARGS__)
 
@@ -517,7 +518,6 @@
     int halfwidth = (width + 1) >> 1;
     int halfheight = (height + 1) >> 1;
 
-    // TODO(b/203141655): avoid unnecessary memory copy by merging libyuv API for rotation.
     uint8_t *rotated_y_ptr =
             static_cast<uint8_t *>(env->GetDirectBufferAddress(rotated_buffer_y));
     uint8_t *rotated_u_ptr =
@@ -532,90 +532,100 @@
     int rotated_stride_u = flip_wh ? halfheight : halfwidth;
     int rotated_stride_v = flip_wh ? halfheight : halfwidth;
 
+    int result = 0;
+
+    // Converts Android420 to I420 format with rotation applied
+    result = libyuv::Android420ToI420Rotate(
+            src_y_ptr,
+            src_stride_y,
+            src_u_ptr,
+            src_stride_u,
+            src_v_ptr,
+            src_stride_v,
+            src_pixel_stride_uv,
+            rotated_y_ptr,
+            rotated_stride_y,
+            rotated_u_ptr,
+            rotated_stride_u,
+            rotated_v_ptr,
+            rotated_stride_v,
+            width,
+            height,
+            mode);
+
+    if (result != 0) {
+        return result;
+    }
+
+    // Convert to the required output format
     int rotated_width = flip_wh ? height : width;
     int rotated_height = flip_wh ? width : height;
     int rotated_halfwidth = flip_wh ? halfheight : halfwidth;
     int rotated_halfheight = flip_wh ? halfwidth : halfheight;
+    const ptrdiff_t vu_off = dst_v_ptr - dst_u_ptr;
 
-    int result = 0;
-    const ptrdiff_t vu_off = src_v_ptr - src_u_ptr;
-
-    if (src_pixel_stride_uv == 1) {
-        // I420
-        result = libyuv::I420Rotate(src_y_ptr,
-                                    src_stride_y,
-                                    src_u_ptr,
-                                    src_stride_u,
-                                    src_v_ptr,
-                                    src_stride_v,
-                                    rotated_y_ptr,
-                                    rotated_stride_y,
-                                    rotated_u_ptr,
-                                    rotated_stride_u,
-                                    rotated_v_ptr,
-                                    rotated_stride_v,
-                                    width,
-                                    height,
-                                    mode);
-    } else if (src_pixel_stride_uv == 2 && vu_off == -1 &&
-               src_stride_u == src_stride_v) {
+    if (dst_pixel_stride_u == 2 && vu_off == -1) {
         // NV21
-        result = libyuv::NV12ToI420Rotate(src_y_ptr,
-                                          src_stride_y,
-                                          src_v_ptr,
-                                          src_stride_v,
-                                          rotated_y_ptr,
-                                          rotated_stride_y,
-                                          rotated_v_ptr,
-                                          rotated_stride_v,
-                                          rotated_u_ptr,
-                                          rotated_stride_u,
-                                          width,
-                                          height,
-                                          mode);
-    } else if (src_pixel_stride_uv == 2 && vu_off == 1 && src_stride_u == src_stride_v) {
+        result = libyuv::I420ToNV21(
+                /* src_y= */ rotated_y_ptr,
+                /* src_stride_y= */ rotated_width,
+                /* src_u= */ rotated_u_ptr,
+                /* src_stride_u= */ rotated_halfwidth,
+                /* src_v= */ rotated_v_ptr,
+                /* src_stride_v= */ rotated_halfwidth,
+                /* dst_y= */ dst_y_ptr,
+                /* dst_stride_y= */ dst_stride_y,
+                /* dst_uv= */ dst_v_ptr,
+                /* dst_stride_uv= */ dst_stride_v,
+                /* width= */ rotated_width,
+                /* height= */ rotated_height
+        );
+    } else if (dst_pixel_stride_u == 2 && vu_off == 1) {
         // NV12
-        result = libyuv::NV12ToI420Rotate(src_y_ptr,
-                                          src_stride_y,
-                                          src_u_ptr,
-                                          src_stride_u,
-                                          rotated_y_ptr,
-                                          rotated_stride_y,
-                                          rotated_u_ptr,
-                                          rotated_stride_u,
-                                          rotated_v_ptr,
-                                          rotated_stride_v,
-                                          width,
-                                          height,
-                                          mode);
+        result = libyuv::I420ToNV12(
+                /* src_y= */ rotated_y_ptr,
+                /* src_stride_y= */ rotated_width,
+                /* src_u= */ rotated_u_ptr,
+                /* src_stride_u= */ rotated_halfwidth,
+                /* src_v= */ rotated_v_ptr,
+                /* src_stride_v= */ rotated_halfwidth,
+                /* dst_y= */ dst_y_ptr,
+                /* dst_stride_y= */ dst_stride_y,
+                /* dst_uv= */ dst_u_ptr,
+                /* dst_stride_uv= */ dst_stride_u,
+                /* width= */ rotated_width,
+                /* height= */ rotated_height
+        );
+    } else if (dst_pixel_stride_u == 1 && dst_pixel_stride_v == 1) {
+        // I420
+        // Copies Y plane
+        libyuv::CopyPlane(
+                /* src_y= */ rotated_y_ptr,
+                /* src_stride_y= */ rotated_width,
+                /* dst_y= */ dst_y_ptr,
+                /* dst_stride_y= */ dst_stride_y,
+                /* width= */ rotated_width,
+                /* height= */ rotated_height);
+        // Copies U plane
+        libyuv::CopyPlane(
+                /* src_y= */ rotated_u_ptr,
+                /* src_stride_y= */ rotated_halfwidth,
+                /* dst_y= */ dst_u_ptr,
+                /* dst_stride_y= */ dst_stride_u,
+                /* width= */ rotated_halfwidth,
+                /* height= */ rotated_halfheight);
+        // Copies V plane
+        libyuv::CopyPlane(
+                /* src_y= */ rotated_v_ptr,
+                /* src_stride_y= */ rotated_halfwidth,
+                /* dst_y= */ dst_v_ptr,
+                /* dst_stride_y= */ dst_stride_v,
+                /* width= */ rotated_halfwidth,
+                /* height= */ rotated_halfheight);
     } else {
-        // General case fallback creates NV12
-        align_buffer_64(plane_uv, halfwidth * 2 * halfheight);
-        uint8_t* dst_uv = plane_uv;
-        for (int y = 0; y < halfheight; y++) {
-            weave_pixels(src_u_ptr, src_v_ptr, src_pixel_stride_uv, dst_uv, halfwidth);
-            src_u_ptr += src_stride_u;
-            src_v_ptr += src_stride_v;
-            dst_uv += halfwidth * 2;
-        }
+        // Fall backs to directly copy according to the dst stride and pixel_stride values if
+        // it is none of the above cases.
 
-        result = libyuv::NV12ToI420Rotate(src_y_ptr,
-                                          src_stride_y,
-                                          plane_uv,
-                                          halfwidth * 2,
-                                          rotated_y_ptr,
-                                          rotated_stride_y,
-                                          rotated_u_ptr,
-                                          rotated_stride_u,
-                                          rotated_v_ptr,
-                                          rotated_stride_v,
-                                          width,
-                                          height,
-                                          mode);
-        free_aligned_buffer_64(plane_uv);
-    }
-
-    if (result == 0) {
         // Y
         uint8_t *dst_y = rotated_y_ptr;
         int rotated_pixel_stride_y = 1;
@@ -625,7 +635,6 @@
                         dst_y[i * rotated_stride_y + j * rotated_pixel_stride_y];
             }
         }
-
         // U
         uint8_t *dst_u = rotated_u_ptr;
         int rotated_pixel_stride_u = 1;
@@ -635,7 +644,6 @@
                         dst_u[i * rotated_stride_u + j * rotated_pixel_stride_u];
             }
         }
-
         // V
         uint8_t *dst_v = rotated_v_ptr;
         int rotated_pixel_stride_v = 1;
@@ -650,4 +658,36 @@
     return result;
 }
 
+JNIEXPORT jint Java_androidx_camera_core_ImageProcessingUtil_nativeGetYUVImageVUOff(
+        JNIEnv* env,
+        jclass,
+        jobject byte_buffer_v,
+        jobject byte_buffer_u) {
+    uint8_t *byte_buffer_v_ptr =
+            static_cast<uint8_t *>(env->GetDirectBufferAddress(byte_buffer_v));
+    uint8_t *byte_buffer_u_ptr =
+            static_cast<uint8_t *>(env->GetDirectBufferAddress(byte_buffer_u));
+    return byte_buffer_v_ptr - byte_buffer_u_ptr;
+}
+
+JNIEXPORT jobject Java_androidx_camera_core_ImageProcessingUtil_nativeCreateNV21ByteBuffers(
+        JNIEnv *env,
+        jclass,
+        jobject byte_buffer,
+        jint vu_data_length) {
+
+    uint8_t *byte_buffer_ptr =
+            static_cast<uint8_t *>(env->GetDirectBufferAddress(byte_buffer));
+
+    // Create the ByteBuffers
+    jobject vByteBuffer = env->NewDirectByteBuffer(byte_buffer_ptr, vu_data_length);
+    jobject uByteBuffer = env->NewDirectByteBuffer(byte_buffer_ptr + 1, vu_data_length);
+
+    jclass pairClass = env->FindClass("android/util/Pair");
+    jmethodID pairConstructor = env->GetMethodID(pairClass, "<init>",
+                                                 "(Ljava/lang/Object;Ljava/lang/Object;)V");
+
+    return env->NewObject(pairClass, pairConstructor, uByteBuffer, vByteBuffer);
+}
+
 }  // extern "C"
\ No newline at end of file
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
index 8059ca7..31a690e 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
@@ -185,6 +185,35 @@
     public static final int OUTPUT_IMAGE_FORMAT_RGBA_8888 = 2;
 
     /**
+     * Images sent to the analyzer will be formatted in NV21.
+     *
+     * <p>All {@link ImageProxy} sent to {@link Analyzer#analyze(ImageProxy)} will be in
+     * {@link ImageFormat#YUV_420_888} format with their image data formatted in NV21.
+     *
+     * <p>The output {@link ImageProxy} has three planes with the order of Y, U, V. The pixel
+     * stride of U or V planes are 2. The byte buffer pointer position of V plane will be ahead
+     * of the position of the U plane. Applications can directly read the <code>plane[2]</code>
+     * to get all the VU interleaved data.
+     *
+     * <p>Due to limitations on some Android devices in producing images in NV21 format, the
+     * {@link android.media.Image} object obtained from {@link ImageProxy#getImage()} will be the
+     * original image produced by the camera  capture pipeline. This may result in discrepancies
+     * between the  {@link android.media.Image} and the {@link ImageProxy}, such as:
+     *
+     * <ul>
+     * <li>Plane data may differ.
+     * <li>Width and height may differ.
+     * <li>Other properties may also differ.
+     * </ul>
+     *
+     * <p>Developers should be aware of these potential differences and use the properties from the
+     * {@link ImageProxy} when necessary.
+     *
+     * @see Builder#setOutputImageFormat(int)
+     */
+    public static final int OUTPUT_IMAGE_FORMAT_NV21 = 3;
+
+    /**
      * Provides a static configuration with implementation-agnostic options.
      */
     @RestrictTo(Scope.LIBRARY_GROUP)
@@ -376,6 +405,8 @@
 
         boolean isYuv2Rgb = getImageFormat() == ImageFormat.YUV_420_888
                 && getOutputImageFormat() == OUTPUT_IMAGE_FORMAT_RGBA_8888;
+        boolean isYuv2Nv21 = getImageFormat() == ImageFormat.YUV_420_888
+                && getOutputImageFormat() == OUTPUT_IMAGE_FORMAT_NV21;
         boolean isYuvRotationOrPixelShift = getImageFormat() == ImageFormat.YUV_420_888
                 && ((getCamera() != null && getRelativeRotation(getCamera()) != 0)
                 || Boolean.TRUE.equals(getOnePixelShiftEnabled()));
@@ -384,7 +415,7 @@
         // supporting RGB natively. The logic here will check if the specific configured size is
         // available in RGB and if not, fall back to YUV-RGB conversion.
         final SafeCloseImageReaderProxy processedImageReaderProxy =
-                (isYuv2Rgb || isYuvRotationOrPixelShift)
+                (isYuv2Rgb || (isYuvRotationOrPixelShift && !isYuv2Nv21))
                         ? new SafeCloseImageReaderProxy(
                         ImageReaderProxys.createIsolatedReader(
                                 width,
@@ -658,8 +689,9 @@
      * Gets output image format.
      *
      * <p>The returned image format will be
-     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888} or
-     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888}.
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888},
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888} or
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_NV21}.
      *
      * @return output image format.
      * @see ImageAnalysis.Builder#setOutputImageFormat(int)
@@ -834,14 +866,16 @@
      * Supported output image format for image analysis.
      *
      * <p>The supported output image format
-     * is {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888} and
-     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888}.
+     * is {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888},
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888} and
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_NV21}.
      *
      * <p>By default, {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888} will be used.
      *
      * @see Builder#setOutputImageFormat(int)
      */
-    @IntDef({OUTPUT_IMAGE_FORMAT_YUV_420_888, OUTPUT_IMAGE_FORMAT_RGBA_8888})
+    @IntDef({OUTPUT_IMAGE_FORMAT_YUV_420_888, OUTPUT_IMAGE_FORMAT_RGBA_8888,
+            OUTPUT_IMAGE_FORMAT_NV21})
     @Retention(RetentionPolicy.SOURCE)
     @RestrictTo(Scope.LIBRARY_GROUP)
     public @interface OutputImageFormat {
@@ -1161,13 +1195,15 @@
          * Sets output image format.
          *
          * <p>The supported output image format
-         * is {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888} and
-         * {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888}.
+         * is {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888},
+         * {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888} and
+         * {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_NV21}.
          *
          * <p>If not set, {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888} will be used.
          *
-         * Requesting {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888} will have extra
-         * overhead because format conversion takes time.
+         * Requesting {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888} or
+         * {@link OutputImageFormat#OUTPUT_IMAGE_FORMAT_NV21} will have extra overhead because
+         * format conversion takes time.
          *
          * @param outputImageFormat The output image format.
          * @return The current Builder.
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysisAbstractAnalyzer.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysisAbstractAnalyzer.java
index c82db31..afbb654 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysisAbstractAnalyzer.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysisAbstractAnalyzer.java
@@ -16,10 +16,12 @@
 
 package androidx.camera.core;
 
+import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21;
 import static androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888;
 import static androidx.camera.core.ImageProcessingUtil.applyPixelShiftForYUV;
 import static androidx.camera.core.ImageProcessingUtil.convertYUVToRGB;
 import static androidx.camera.core.ImageProcessingUtil.rotateYUV;
+import static androidx.camera.core.ImageProcessingUtil.rotateYUVAndConvertToNV21;
 import static androidx.camera.core.impl.utils.TransformUtils.NORMALIZED_RECT;
 import static androidx.camera.core.impl.utils.TransformUtils.getNormalizedToBuffer;
 
@@ -109,6 +111,12 @@
     @GuardedBy("mAnalyzerLock")
     @VisibleForTesting @Nullable ByteBuffer mVRotatedBuffer;
 
+    @GuardedBy("mAnalyzerLock")
+    @VisibleForTesting @Nullable ByteBuffer mNV21YDelegatedBuffer;
+
+    @GuardedBy("mAnalyzerLock")
+    @VisibleForTesting @Nullable ByteBuffer mNV21UVDelegatedBuffer;
+
     // Lock that synchronizes the access to mSubscribedAnalyzer/mUserExecutor to prevent mismatch.
     private final Object mAnalyzerLock = new Object();
 
@@ -168,6 +176,8 @@
         ByteBuffer yRotatedBuffer;
         ByteBuffer uRotatedBuffer;
         ByteBuffer vRotatedBuffer;
+        ByteBuffer nv21YDelegatedBuffer;
+        ByteBuffer nv21UVDelegatedBuffer;
         int currentBufferRotationDegrees = mOutputImageRotationEnabled ? mRelativeRotation : 0;
         boolean outputImageDirty;
 
@@ -187,7 +197,7 @@
             }
 
             // Cache memory buffer for image rotation
-            if (mOutputImageRotationEnabled) {
+            if (mOutputImageRotationEnabled || mOutputImageFormat == OUTPUT_IMAGE_FORMAT_NV21) {
                 createHelperBuffer(imageProxy);
             }
 
@@ -197,6 +207,8 @@
             yRotatedBuffer = mYRotatedBuffer;
             uRotatedBuffer = mURotatedBuffer;
             vRotatedBuffer = mVRotatedBuffer;
+            nv21YDelegatedBuffer = mNV21YDelegatedBuffer;
+            nv21UVDelegatedBuffer = mNV21UVDelegatedBuffer;
         }
 
         ListenableFuture<Void> future;
@@ -232,6 +244,25 @@
                                 currentBufferRotationDegrees);
                     }
                 }
+            } else if (mOutputImageFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+                // Apply one pixel shift before other processing, e.g. rotation.
+                if (mOnePixelShiftEnabled) {
+                    applyPixelShiftForYUV(imageProxy);
+                }
+                if (yRotatedBuffer != null
+                        && uRotatedBuffer != null
+                        && vRotatedBuffer != null
+                        && nv21YDelegatedBuffer != null
+                        && nv21UVDelegatedBuffer != null) {
+                    processedImageProxy = rotateYUVAndConvertToNV21(
+                            imageProxy,
+                            yRotatedBuffer,
+                            uRotatedBuffer,
+                            vRotatedBuffer,
+                            nv21YDelegatedBuffer,
+                            nv21UVDelegatedBuffer,
+                            currentBufferRotationDegrees);
+                }
             }
 
             // Flag to indicate YUV2RGB conversion or YUV/RGB rotation failed, not including one
@@ -346,7 +377,6 @@
         synchronized (mAnalyzerLock) {
             mProcessedImageReaderProxy = processedImageReaderProxy;
         }
-
     }
 
     void setAnalyzer(@Nullable Executor userExecutor,
@@ -378,7 +408,8 @@
 
     @GuardedBy("mAnalyzerLock")
     private void createHelperBuffer(@NonNull ImageProxy imageProxy) {
-        if (mOutputImageFormat == ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) {
+        if (mOutputImageFormat == ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888
+                || mOutputImageFormat == OUTPUT_IMAGE_FORMAT_NV21) {
             if (mYRotatedBuffer == null) {
                 mYRotatedBuffer = ByteBuffer.allocateDirect(
                         imageProxy.getWidth() * imageProxy.getHeight());
@@ -396,6 +427,20 @@
                         imageProxy.getWidth() * imageProxy.getHeight() / 4);
             }
             mVRotatedBuffer.position(0);
+            if (mOutputImageFormat == OUTPUT_IMAGE_FORMAT_NV21) {
+                if (mNV21YDelegatedBuffer == null) {
+                    mNV21YDelegatedBuffer = ByteBuffer.allocateDirect(
+                            imageProxy.getWidth() * imageProxy.getHeight());
+                }
+                mNV21YDelegatedBuffer.position(0);
+                if (mNV21UVDelegatedBuffer == null) {
+                    // This will be converted into U, V child ByteBuffers for NV21 format
+                    // conversion process
+                    mNV21UVDelegatedBuffer = ByteBuffer.allocateDirect(
+                            imageProxy.getWidth() * imageProxy.getHeight() / 2);
+                }
+                mNV21UVDelegatedBuffer.position(0);
+            }
         } else if (mOutputImageFormat == OUTPUT_IMAGE_FORMAT_RGBA_8888) {
             if (mRGBConvertedBuffer == null) {
                 mRGBConvertedBuffer = ByteBuffer.allocateDirect(
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessingUtil.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessingUtil.java
index a7159e1..432d856 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessingUtil.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageProcessingUtil.java
@@ -25,6 +25,7 @@
 import android.media.ImageWriter;
 import android.os.Build;
 import android.util.Log;
+import android.util.Pair;
 import android.view.Surface;
 
 import androidx.annotation.IntRange;
@@ -388,6 +389,92 @@
         return wrappedRotatedImageProxy;
     }
 
+
+    /**
+     * Rotates YUV image proxy and output the NV21 format image proxy with the delegated byte
+     * buffers.
+     *
+     * @param imageProxy              input image proxy.
+     * @param yRotatedBuffer          intermediate image buffer for y plane rotation.
+     * @param uRotatedBuffer          intermediate image buffer for u plane rotation.
+     * @param vRotatedBuffer          intermediate image buffer for v plane rotation.
+     * @param nv21YDelegatedBuffer    delegated image buffer for y plane.
+     * @param nv21UVDelegatedBuffer   delegated image buffer for u/v plane.
+     * @param rotationDegrees         output image rotation degrees.
+     * @return rotated image proxy or null if rotation fails or format is not supported.
+     */
+    public static @Nullable ImageProxy rotateYUVAndConvertToNV21(
+            @NonNull ImageProxy imageProxy,
+            @NonNull ByteBuffer yRotatedBuffer,
+            @NonNull ByteBuffer uRotatedBuffer,
+            @NonNull ByteBuffer vRotatedBuffer,
+            @NonNull ByteBuffer nv21YDelegatedBuffer,
+            @NonNull ByteBuffer nv21UVDelegatedBuffer,
+            @IntRange(from = 0, to = 359) int rotationDegrees) {
+        if (!isSupportedYUVFormat(imageProxy)) {
+            Logger.e(TAG, "Unsupported format for rotate YUV");
+            return null;
+        }
+
+        if (!isSupportedRotationDegrees(rotationDegrees)) {
+            Logger.e(TAG, "Unsupported rotation degrees for rotate YUV");
+            return null;
+        }
+
+        // If both rotation and format conversion processing are unnecessary, directly return here.
+        if (rotationDegrees == 0 && isNV21FormatImage(imageProxy)) {
+            return null;
+        }
+
+        int rotatedWidth =
+                (rotationDegrees % 180 == 0) ? imageProxy.getWidth() : imageProxy.getHeight();
+        int rotatedHeight =
+                (rotationDegrees % 180 == 0) ? imageProxy.getHeight() : imageProxy.getWidth();
+
+        Pair<ByteBuffer, ByteBuffer> nv21UVByteBuffers = nativeCreateNV21ByteBuffers(
+                nv21UVDelegatedBuffer, nv21UVDelegatedBuffer.capacity());
+
+        int result = nativeRotateYUV(
+                imageProxy.getPlanes()[0].getBuffer(),
+                imageProxy.getPlanes()[0].getRowStride(),
+                imageProxy.getPlanes()[1].getBuffer(),
+                imageProxy.getPlanes()[1].getRowStride(),
+                imageProxy.getPlanes()[2].getBuffer(),
+                imageProxy.getPlanes()[2].getRowStride(),
+                imageProxy.getPlanes()[2].getPixelStride(),
+                nv21YDelegatedBuffer,
+                rotatedWidth,
+                1,
+                nv21UVByteBuffers.first,
+                rotatedWidth,
+                2,
+                nv21UVByteBuffers.second,
+                rotatedWidth,
+                2,
+                yRotatedBuffer,
+                uRotatedBuffer,
+                vRotatedBuffer,
+                imageProxy.getWidth(),
+                imageProxy.getHeight(),
+                rotationDegrees);
+
+        if (result != 0) {
+            Logger.e(TAG, "rotate YUV failure");
+            return null;
+        }
+
+        // Wraps to NV21ImageProxy to make sure that the returned v plane position is in front of u
+        // plane position.
+        return new SingleCloseImageProxy(
+                new NV21ImageProxy(imageProxy,
+                        nv21YDelegatedBuffer,
+                        nv21UVByteBuffers.first,
+                        nv21UVByteBuffers.second,
+                        rotatedWidth,
+                        rotatedHeight,
+                        rotationDegrees));
+    }
+
     private static boolean isSupportedYUVFormat(@NonNull ImageProxy imageProxy) {
         return imageProxy.getFormat() == ImageFormat.YUV_420_888
                 && imageProxy.getPlanes().length == 3;
@@ -527,6 +614,116 @@
         return SUCCESS;
     }
 
+    /**
+     * Checks whether the image proxy data is formatted in NV21.
+     */
+    public static boolean isNV21FormatImage(@NonNull ImageProxy imageProxy) {
+        if (imageProxy.getPlanes().length != 3) {
+            return false;
+        }
+        if (imageProxy.getPlanes()[1].getPixelStride() != 2) {
+            return false;
+        }
+        return nativeGetYUVImageVUOff(
+                imageProxy.getPlanes()[2].getBuffer(),
+                imageProxy.getPlanes()[1].getBuffer()) == -1;
+    }
+
+    /**
+     * A wrapper to make sure that the returned v plane position (getPlanes()[2]) is in front of
+     * u plane position (getPlanes()[1]). So that the following operations can correctly check
+     * whether the format is NV12 or NV21 by the plane buffers' pointer positions.
+     *
+     * <p>The callers need to ensure that the v data is put in the plane with former position and v
+     * data is put in the plane with the later position in the associated image proxy.
+     */
+    private static class NV21ImageProxy extends ForwardingImageProxy {
+        private final ImageProxy.PlaneProxy[] mPlanes;
+        private final int mWidth;
+        private final int mHeight;
+
+        NV21ImageProxy(@NonNull ImageProxy imageProxy,
+                @NonNull ByteBuffer delegateBufferY,
+                @NonNull ByteBuffer delegateBufferU,
+                @NonNull ByteBuffer delegateBufferV,
+                int width, int height,
+                @IntRange(from = 0, to = 359) int rotatedRotationDegrees) {
+            super(imageProxy);
+            mPlanes = createPlanes(delegateBufferY, delegateBufferU, delegateBufferV, width);
+            mWidth = width;
+            mHeight = height;
+        }
+
+        @Override
+        public int getHeight() {
+            return mHeight;
+        }
+
+        @Override
+        public int getWidth() {
+            return mWidth;
+        }
+
+        @Override
+        public ImageProxy.PlaneProxy @NonNull [] getPlanes() {
+            return mPlanes;
+        }
+
+        private ImageProxy.PlaneProxy @NonNull [] createPlanes(
+                @NonNull ByteBuffer delegateBufferY,
+                @NonNull ByteBuffer delegateBufferU,
+                @NonNull ByteBuffer delegateBufferV,
+                int rowStride
+        ) {
+            ImageProxy.PlaneProxy[] planes = new ImageProxy.PlaneProxy[3];
+            planes[0] = new ImageProxy.PlaneProxy() {
+                @Override
+                public int getRowStride() {
+                    return rowStride;
+                }
+
+                @Override
+                public int getPixelStride() {
+                    return 1;
+                }
+
+                @Override
+                public @NonNull ByteBuffer getBuffer() {
+                    return delegateBufferY;
+                }
+            };
+            planes[1] = new NV21PlaneProxy(delegateBufferU, rowStride);
+            planes[2] = new NV21PlaneProxy(delegateBufferV, rowStride);
+
+            return planes;
+        }
+    }
+
+    private static class NV21PlaneProxy implements ImageProxy.PlaneProxy {
+        private final ByteBuffer mByteBuffer;
+        private final int mRowStride;
+
+        NV21PlaneProxy(@NonNull ByteBuffer byteBuffer, int rowStride) {
+            mByteBuffer = byteBuffer;
+            mRowStride = rowStride;
+        }
+
+        @Override
+        public int getRowStride() {
+            return mRowStride;
+        }
+
+        @Override
+        public int getPixelStride() {
+            // Force return pixel stride value 2
+            return 2;
+        }
+
+        @Override
+        public @NonNull ByteBuffer getBuffer() {
+            return mByteBuffer;
+        }
+    }
 
     private static native int nativeCopyBetweenByteBufferAndBitmap(Bitmap bitmap,
             ByteBuffer byteBuffer,
@@ -607,4 +804,14 @@
             int width,
             int height,
             @ImageOutputConfig.RotationDegreesValue int rotationDegrees);
+
+    private static native int nativeGetYUVImageVUOff(
+            @NonNull ByteBuffer srcByteBufferV,
+            @NonNull ByteBuffer srcByteBufferU
+    );
+
+    private static native Pair<ByteBuffer, ByteBuffer> nativeCreateNV21ByteBuffers(
+            @NonNull ByteBuffer byteBuffer,
+            int vuDataLength
+    );
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ImageAnalysisConfig.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ImageAnalysisConfig.java
index 8581ffb..ebaf412 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/ImageAnalysisConfig.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ImageAnalysisConfig.java
@@ -135,8 +135,9 @@
      * Returns the output image format for image analysis.
      *
      * <p>The supported output image format
-     * is {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888} and
-     * {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888}.
+     * is {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888},
+     * {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888} and
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_NV21}.
      *
      * @param valueIfMissing The value to return if this configuration option has not been set.
      * @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompat.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompat.java
index 5774ad7..ed59b2a 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompat.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompat.java
@@ -19,6 +19,7 @@
 import android.media.Image;
 import android.media.ImageWriter;
 import android.os.Build;
+import android.os.Handler;
 import android.view.Surface;
 
 import androidx.annotation.IntRange;
@@ -26,6 +27,8 @@
 
 import org.jspecify.annotations.NonNull;
 
+import java.util.concurrent.Executor;
+
 /**
  * Helper for accessing features of {@link ImageWriter} in a backwards compatible fashion.
  */
@@ -157,6 +160,25 @@
     }
 
     /**
+     * Sets an {@link ImageWriter.OnImageReleasedListener} to be notified when an {@link Image} is
+     * released from the {@link ImageWriter}.
+     *
+     * <p>This method is a compatibility wrapper for
+     * {@link ImageWriter#setOnImageReleasedListener(ImageWriter.OnImageReleasedListener, Handler)}.
+     *
+     * @param imageWriter The {@link ImageWriter} to set the listener on.
+     * @param releasedListener The {@link ImageWriter.OnImageReleasedListener} to be notified when
+     *                        an image is released.
+     * @param executor The {@link Executor} on which the listener should be invoked.
+     */
+    public static void setOnImageReleasedListener(@NonNull ImageWriter imageWriter,
+            ImageWriter.@NonNull OnImageReleasedListener releasedListener, @NonNull
+            Executor executor) {
+        ImageWriterCompatApi23Impl.setOnImageReleasedListener(imageWriter, releasedListener,
+                executor);
+    }
+
+    /**
      * Close the existing ImageWriter instance.
      *
      * @param imageWriter ImageWriter instance.
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompatApi23Impl.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompatApi23Impl.java
index af28bfdd..f083c14 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompatApi23Impl.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/ImageWriterCompatApi23Impl.java
@@ -22,9 +22,12 @@
 
 import androidx.annotation.IntRange;
 import androidx.annotation.RequiresApi;
+import androidx.camera.core.impl.utils.MainThreadAsyncHandler;
 
 import org.jspecify.annotations.NonNull;
 
+import java.util.concurrent.Executor;
+
 @RequiresApi(23)
 final class ImageWriterCompatApi23Impl {
 
@@ -45,6 +48,14 @@
         imageWriter.close();
     }
 
+    static void setOnImageReleasedListener(@NonNull ImageWriter imageWriter,
+            ImageWriter.@NonNull OnImageReleasedListener releasedListener,
+            @NonNull Executor executor) {
+        imageWriter.setOnImageReleasedListener(
+                writer -> executor.execute(() -> releasedListener.onImageReleased(writer)),
+                MainThreadAsyncHandler.getInstance());
+    }
+
     // Class should not be instantiated.
     private ImageWriterCompatApi23Impl() {
     }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ZslRingBuffer.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ZslRingBuffer.java
index c7bbcae..f799573 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ZslRingBuffer.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ZslRingBuffer.java
@@ -56,6 +56,9 @@
     private boolean isValidZslFrame(@NonNull ImageInfo imageInfo) {
         CameraCaptureResult cameraCaptureResult =
                 CameraCaptureResults.retrieveCameraCaptureResult(imageInfo);
+        if (cameraCaptureResult == null) {
+            return false;
+        }
 
         if (cameraCaptureResult.getAfState() != AfState.LOCKED_FOCUSED
                 && cameraCaptureResult.getAfState() != AfState.PASSIVE_FOCUSED)  {
diff --git a/camera/camera-effects-still-portrait/api/1.3.0-beta01.txt b/camera/camera-effects-still-portrait/api/1.3.0-beta01.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.3.0-beta01.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/1.3.0-beta02.txt b/camera/camera-effects-still-portrait/api/1.3.0-beta02.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.3.0-beta02.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/1.3.0-beta03.txt b/camera/camera-effects-still-portrait/api/1.3.0-beta03.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.3.0-beta03.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/1.4.0-beta01.txt b/camera/camera-effects-still-portrait/api/1.4.0-beta01.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.4.0-beta01.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/1.4.0-beta02.txt b/camera/camera-effects-still-portrait/api/1.4.0-beta02.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.4.0-beta02.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/1.4.0-beta03.txt b/camera/camera-effects-still-portrait/api/1.4.0-beta03.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/1.4.0-beta03.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/res-1.3.0-beta01.txt b/camera/camera-effects-still-portrait/api/res-1.3.0-beta01.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.3.0-beta01.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/res-1.3.0-beta02.txt b/camera/camera-effects-still-portrait/api/res-1.3.0-beta02.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.3.0-beta02.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/res-1.3.0-beta03.txt b/camera/camera-effects-still-portrait/api/res-1.3.0-beta03.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.3.0-beta03.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/res-1.4.0-beta01.txt b/camera/camera-effects-still-portrait/api/res-1.4.0-beta01.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.4.0-beta01.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/res-1.4.0-beta02.txt b/camera/camera-effects-still-portrait/api/res-1.4.0-beta02.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.4.0-beta02.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/res-1.4.0-beta03.txt b/camera/camera-effects-still-portrait/api/res-1.4.0-beta03.txt
deleted file mode 100644
index e69de29..0000000
--- a/camera/camera-effects-still-portrait/api/res-1.4.0-beta03.txt
+++ /dev/null
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta01.txt b/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta01.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta01.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta02.txt b/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta02.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta02.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta03.txt b/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta03.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.3.0-beta03.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta01.txt b/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta01.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta01.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta02.txt b/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta02.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta02.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta03.txt b/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta03.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_1.4.0-beta03.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/api/restricted_current.txt b/camera/camera-effects-still-portrait/api/restricted_current.txt
deleted file mode 100644
index e6f50d0..0000000
--- a/camera/camera-effects-still-portrait/api/restricted_current.txt
+++ /dev/null
@@ -1 +0,0 @@
-// Signature format: 4.0
diff --git a/camera/camera-effects-still-portrait/build.gradle b/camera/camera-effects-still-portrait/build.gradle
deleted file mode 100644
index 4217887..0000000
--- a/camera/camera-effects-still-portrait/build.gradle
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2022 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.
- */
-import androidx.build.Publish
-import androidx.build.RunApiTasks
-
-plugins {
-    id("AndroidXPlugin")
-    id("com.android.library")
-    id("kotlin-android")
-}
-dependencies {
-    api(libs.jspecify)
-    api(project(":camera:camera-core"))
-}
-android {
-    testOptions.unitTests.includeAndroidResources = true
-    namespace = "androidx.camera.effects.stillportrait"
-}
-androidx {
-    name = "Camera Effects: Still Portrait"
-    publish = Publish.SNAPSHOT_ONLY
-    inceptionYear = "2022"
-    runApiTasks = new RunApiTasks.Yes()
-    description = "A post-processing effect that works with CameraX Library, providing a portrait" +
-            " mode effect that applies to still image captures."
-}
diff --git a/camera/camera-effects-still-portrait/src/main/java/androidx/camera/effects/stillportrait/StillPortrait.java b/camera/camera-effects-still-portrait/src/main/java/androidx/camera/effects/stillportrait/StillPortrait.java
deleted file mode 100644
index 399dd04..0000000
--- a/camera/camera-effects-still-portrait/src/main/java/androidx/camera/effects/stillportrait/StillPortrait.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2022 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 androidx.camera.effects.stillportrait;
-
-import androidx.annotation.RestrictTo;
-import androidx.camera.core.CameraEffect;
-import androidx.camera.core.SurfaceProcessor;
-import androidx.camera.core.UseCase;
-
-import org.jspecify.annotations.NonNull;
-
-import java.util.concurrent.Executor;
-
-/**
- * Provides a portrait post-processing effect.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class StillPortrait extends CameraEffect {
-
-    /**
-     * @param targets           the target {@link UseCase} to which this effect should be applied.
-     * @param processorExecutor the {@link Executor} on which the processor will be invoked.
-     * @param surfaceProcessor  a {@link SurfaceProcessor} implementation.
-     */
-    protected StillPortrait(int targets,
-            @NonNull Executor processorExecutor,
-            @NonNull SurfaceProcessor surfaceProcessor) {
-        super(targets, processorExecutor, surfaceProcessor, throwable -> {
-        });
-        // TODO: implement this.
-    }
-}
diff --git a/camera/camera-feature-combination-query-play-services/build.gradle b/camera/camera-feature-combination-query-play-services/build.gradle
index ef70110..0074f43 100644
--- a/camera/camera-feature-combination-query-play-services/build.gradle
+++ b/camera/camera-feature-combination-query-play-services/build.gradle
@@ -25,7 +25,6 @@
 dependencies {
     api(libs.jspecify)
     api(libs.androidx.annotation)
-    project(":camera:camera-feature-combination-query")
     implementation(project(":camera:camera-feature-combination-query"))
 
     testImplementation(libs.testRunner)
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/CameraUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/CameraUtil.java
index d7bd592..a6be267 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/CameraUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/CameraUtil.java
@@ -430,7 +430,6 @@
             mCameraCaptureSession = openFuture.get(5, TimeUnit.SECONDS);
         }
 
-        @SuppressLint("ClassVerificationFailure")
         @SuppressWarnings({"deprecation", "newApi", "unchecked"})
         private @NonNull ListenableFuture<CameraCaptureSession> openCaptureSession(
                 @NonNull CameraDevice cameraDevice,
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/IgnoreProblematicDeviceRule.kt b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/IgnoreProblematicDeviceRule.kt
index 416c89b..c3c69de 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/IgnoreProblematicDeviceRule.kt
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/IgnoreProblematicDeviceRule.kt
@@ -71,6 +71,10 @@
             isEmulator &&
                 avdName.contains("Pixel2", ignoreCase = true) &&
                 Build.VERSION.SDK_INT == Build.VERSION_CODES.O
+        public val isPixel2Api28Emulator: Boolean =
+            isEmulator &&
+                avdName.contains("Pixel2", ignoreCase = true) &&
+                Build.VERSION.SDK_INT == Build.VERSION_CODES.P
         public val isPixel2Api30Emulator: Boolean =
             isEmulator &&
                 avdName.contains("Pixel2", ignoreCase = true) &&
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
index a01450a..0c689dc 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
@@ -46,6 +46,7 @@
 import org.jspecify.annotations.NonNull;
 
 import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
 
 /**
  * Generates images for testing.
@@ -90,22 +91,78 @@
     private TestImageUtil() {
     }
 
-
     /**
      * Creates a [FakeImageProxy] with YUV format.
-     *
-     * TODO(b/245940015): fix the content of the image to match the value of {@link #createBitmap}.
      */
     public static @NonNull FakeImageProxy createYuvFakeImageProxy(@NonNull ImageInfo imageInfo,
             int width, int height) {
+        return createYuvFakeImageProxy(imageInfo, width, height, false, false);
+    }
+
+    /**
+     * Creates a [FakeImageProxy] with YUV format with the content of the image to match the
+     * value of {@link #createBitmap}.
+     */
+    @NonNull
+    public static FakeImageProxy createYuvFakeImageProxy(@NonNull ImageInfo imageInfo,
+            int width, int height, boolean flipUV, boolean insertRgbTestData) {
         FakeImageProxy image = new FakeImageProxy(imageInfo);
         image.setFormat(YUV_420_888);
-        image.setPlanes(createYUV420ImagePlanes(width, height, 1, 1, false, false));
+        image.setPlanes(createYUV420ImagePlanes(width, height, 1, 1, flipUV, false));
         image.setWidth(width);
         image.setHeight(height);
+
+        // Directly returns the image if RGB test data is not needed.
+        if (!insertRgbTestData) {
+            return image;
+        }
+
+        Bitmap rgbBitmap = createBitmap(width, height);
+        writeBitmapToYuvByteBuffers(rgbBitmap,
+                image.getPlanes()[0].getBuffer(),
+                image.getPlanes()[1].getBuffer(),
+                image.getPlanes()[2].getBuffer());
+
         return image;
     }
 
+    private static void writeBitmapToYuvByteBuffers(
+            @NonNull Bitmap bitmap,
+            @NonNull ByteBuffer yByteBuffer,
+            @NonNull ByteBuffer uByteBuffer,
+            @NonNull ByteBuffer vByteBuffer) {
+        int width = bitmap.getWidth();
+        int height = bitmap.getHeight();
+        int[] argb = new int[width * height];
+        bitmap.getPixels(argb, 0, width, 0, 0, width, height);
+
+        int yIndex = 0;
+        int uvIndex = 0;
+
+        for (int j = 0; j < height; j++) {
+            for (int i = 0; i < width; i++) {
+                int rgb = argb[j * width + i];
+                int r = (rgb >> 16) & 0xFF;
+                int g = (rgb >> 8) & 0xFF;
+                int b = rgb & 0xFF;
+
+                int y = (int) (0.299 * r + 0.587 * g + 0.114 * b);
+                int u = (int) (-0.169 * r - 0.331 * g + 0.5 * b + 128);
+                int v = (int) (0.5 * r - 0.419 * g - 0.081 * b + 128);
+
+                yByteBuffer.put(yIndex++, (byte) y);
+                if (j % 2 == 0 && i % 2 == 0) {
+                    uByteBuffer.put(uvIndex, (byte) u);
+                    vByteBuffer.put(uvIndex, (byte) v);
+                    uvIndex++;
+                }
+            }
+        }
+        yByteBuffer.rewind();
+        uByteBuffer.rewind();
+        vByteBuffer.rewind();
+    }
+
     /**
      * Creates a [FakeImageProxy] with [RAW_SENSOR] format.
      */
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java b/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
index 2e3fb06..f720b8e 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/CameraController.java
@@ -1421,8 +1421,9 @@
      * <p>If not set, {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_YUV_420_888}
      * will be used.
      *
-     * <p>Requesting {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888}
-     * causes extra overhead because format conversion takes time.
+     * <p>Requesting {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_RGBA_8888} or
+     * {@link ImageAnalysis.OutputImageFormat#OUTPUT_IMAGE_FORMAT_NV21} causes extra overhead
+     * because format conversion takes time.
      *
      * <p>Changing the value will reconfigure the camera, which may cause additional latency. To
      * avoid this, set the value before controller is bound to lifecycle. If the value is changed
@@ -1449,8 +1450,9 @@
      * Gets the output image format for {@link ImageAnalysis}.
      *
      * <p>The returned image format can be either
-     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888} or
-     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888}.
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_YUV_420_888},
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_RGBA_8888} or
+     * {@link ImageAnalysis#OUTPUT_IMAGE_FORMAT_NV21}.
      *
      * @see ImageAnalysis.Builder#setOutputImageFormat(int)
      * @see ImageAnalysis.Builder#getOutputImageFormat()
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FlashTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FlashTest.kt
index 2e8fc9a..ce17ca5 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FlashTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FlashTest.kt
@@ -195,16 +195,19 @@
         )
     }
 
+    @LabTestRule.LabTestRearCamera
     @Test
     fun requestAeModeIsOnAlwaysFlash_whenCapturedWithFlashOn() {
         verifyRequestAeOrFlashModeForFlashModeCapture(ImageCapture.FLASH_MODE_ON)
     }
 
+    @LabTestRule.LabTestRearCamera
     @Test
     fun requestAeModeIsOnAutoFlash_whenCapturedWithFlashAuto() {
         verifyRequestAeOrFlashModeForFlashModeCapture(ImageCapture.FLASH_MODE_AUTO)
     }
 
+    @LabTestRule.LabTestRearCamera
     @Test
     fun flashEnabledInRequest_whenCapturedWithFlashOnAndSharedEffect() {
         verifyRequestAeOrFlashModeForFlashModeCapture(
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageProcessingLatencyTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageProcessingLatencyTest.kt
index 83f9d1d..ecd51a6 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageProcessingLatencyTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageProcessingLatencyTest.kt
@@ -21,6 +21,7 @@
 import androidx.camera.camera2.Camera2Config
 import androidx.camera.core.CameraSelector
 import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21
 import androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888
 import androidx.camera.core.Logger
 import androidx.camera.core.internal.CameraUseCaseAdapter
@@ -90,18 +91,30 @@
 
     @LabTestRule.LabTestRearCamera
     @Test
-    fun imageProcessingMeasurementViaRearCamera() {
-        measureImageProcessing(CameraSelector.LENS_FACING_BACK)
+    fun imageProcessingMeasurementViaRearCamera_RGBA_8888() {
+        measureImageProcessing(CameraSelector.LENS_FACING_BACK, OUTPUT_IMAGE_FORMAT_RGBA_8888)
     }
 
     @LabTestRule.LabTestFrontCamera
     @Test
-    fun imageProcessingMeasurementViaFrontCamera() {
-        measureImageProcessing(CameraSelector.LENS_FACING_FRONT)
+    fun imageProcessingMeasurementViaFrontCamera_RGBA_8888() {
+        measureImageProcessing(CameraSelector.LENS_FACING_FRONT, OUTPUT_IMAGE_FORMAT_RGBA_8888)
+    }
+
+    @LabTestRule.LabTestRearCamera
+    @Test
+    fun imageProcessingMeasurementViaRearCamera_NV21() {
+        measureImageProcessing(CameraSelector.LENS_FACING_BACK, OUTPUT_IMAGE_FORMAT_NV21)
+    }
+
+    @LabTestRule.LabTestFrontCamera
+    @Test
+    fun imageProcessingMeasurementViaFrontCamera_NV21() {
+        measureImageProcessing(CameraSelector.LENS_FACING_FRONT, OUTPUT_IMAGE_FORMAT_NV21)
     }
 
     @Suppress("DEPRECATION") // legacy resolution API
-    private fun measureImageProcessing(lensFacing: Int): Unit = runBlocking {
+    private fun measureImageProcessing(lensFacing: Int, outputFormat: Int): Unit = runBlocking {
         // The log is used to profile the ImageProcessing performance. The log parser identifies
         // the log pattern "Image processing performance profiling" in the device output log.
         Logger.d(
@@ -113,7 +126,7 @@
         val countDownLatch = CountDownLatch(200)
         val imageAnalyzer =
             ImageAnalysis.Builder()
-                .setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888)
+                .setOutputImageFormat(outputFormat)
                 .setTargetResolution(targetResolution)
                 .build()
                 .also {
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/UseCaseCombinationTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/UseCaseCombinationTest.kt
index f8a913b..defa125 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/UseCaseCombinationTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/UseCaseCombinationTest.kt
@@ -17,6 +17,7 @@
 
 import android.Manifest
 import android.content.Context
+import android.util.Log
 import androidx.camera.camera2.Camera2Config
 import androidx.camera.camera2.pipe.integration.CameraPipeConfig
 import androidx.camera.core.Camera
@@ -435,6 +436,28 @@
         imageCapture.waitForCapturing()
     }
 
+    @Test
+    fun previewImageCaptureZSL() {
+        // Arrange.
+        assumeTrue(cameraInfo.isZslSupported) // Only test when ZSL is supported
+
+        val imageCaptureZSL =
+            ImageCapture.Builder()
+                .apply { setCaptureMode(ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG) }
+                .build()
+        assumeTrue(camera.isUseCasesCombinationSupported(preview, imageCaptureZSL))
+        bindUseCases(preview, imageCaptureZSL)
+
+        // Capture images with ZSL and verify each capture.
+        for (i in 10 downTo 0) {
+            previewMonitor.waitForStream()
+            imageCaptureZSL.waitForCapturing()
+            Log.d("UseCaseCombinationTest", "Test ZSL capture round: $i")
+            // Verifies the preview is still outputting after capture
+            previewMonitor.waitForStream()
+        }
+    }
+
     // Possible for QR code scanning use case.
     @Test
     fun sequentialBindPreviewAndImageAnalysis() {
diff --git a/camera/integration-tests/diagnosetestapp/build.gradle b/camera/integration-tests/diagnosetestapp/build.gradle
index a3e6917..70a9f1d 100644
--- a/camera/integration-tests/diagnosetestapp/build.gradle
+++ b/camera/integration-tests/diagnosetestapp/build.gradle
@@ -56,7 +56,7 @@
     implementation(libs.material)
     testImplementation(libs.robolectric)
     testImplementation(libs.truth)
-    testImplementation project(":camera:camera-testing")
+    testImplementation(project(":camera:camera-testing"))
     testImplementation("androidx.test:core:1.4.0")
     testImplementation("junit:junit:4.12")
 
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
index 22ad5ce..df1fcba 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
@@ -16,7 +16,6 @@
 
 package androidx.camera.integration.extensions.utils
 
-import android.annotation.SuppressLint
 import android.content.Context
 import android.graphics.ImageFormat
 import android.graphics.Point
@@ -100,7 +99,6 @@
         throw IllegalArgumentException("Can't find camera of lens facing $lensFacing")
     }
 
-    @SuppressLint("ClassVerificationFailure")
     @RequiresApi(31)
     @JvmStatic
     fun isCamera2ExtensionModeSupported(
@@ -117,7 +115,6 @@
      * Picks a preview resolution that is both close/same as the display size and supported by
      * camera and extensions.
      */
-    @SuppressLint("ClassVerificationFailure")
     @RequiresApi(Build.VERSION_CODES.S)
     @JvmStatic
     fun pickPreviewResolution(
@@ -208,7 +205,6 @@
     }
 
     /** Picks a resolution for still image capture. */
-    @SuppressLint("ClassVerificationFailure")
     @RequiresApi(Build.VERSION_CODES.S)
     @JvmStatic
     fun pickStillImageResolution(
diff --git a/camera/viewfinder/viewfinder-compose/build.gradle b/camera/viewfinder/viewfinder-compose/build.gradle
index d20e198..b28d873 100644
--- a/camera/viewfinder/viewfinder-compose/build.gradle
+++ b/camera/viewfinder/viewfinder-compose/build.gradle
@@ -51,9 +51,6 @@
 android {
     compileSdk = 35
     namespace = "androidx.camera.viewfinder.compose"
-
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/camera/viewfinder/viewfinder-compose"
 }
 
 androidx {
@@ -62,4 +59,5 @@
     inceptionYear = "2023"
     description = "Standalone Composable Viewfinder for Camera"
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
diff --git a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/ViewfinderSurfaceRequestExt.kt b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/ViewfinderSurfaceRequestExt.kt
index 5379f22..a5eeb04 100644
--- a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/ViewfinderSurfaceRequestExt.kt
+++ b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/ViewfinderSurfaceRequestExt.kt
@@ -17,7 +17,6 @@
 
 package androidx.camera.viewfinder.core
 
-import android.annotation.SuppressLint
 import android.hardware.camera2.CameraCharacteristics
 import android.hardware.camera2.CameraMetadata
 import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest.Companion.MIRROR_MODE_HORIZONTAL
@@ -30,7 +29,6 @@
  * orientation and [ImplementationMode]. If the hardware level is legacy, the [ImplementationMode]
  * will be set to [ImplementationMode.EMBEDDED].
  */
-@SuppressLint("ClassVerificationFailure")
 fun ViewfinderSurfaceRequest.Builder.populateFromCharacteristics(
     cameraCharacteristics: CameraCharacteristics
 ): ViewfinderSurfaceRequest.Builder {
diff --git a/car/app/app-automotive/build.gradle b/car/app/app-automotive/build.gradle
index ebde05b..d9ce614 100644
--- a/car/app/app-automotive/build.gradle
+++ b/car/app/app-automotive/build.gradle
@@ -53,7 +53,7 @@
     testImplementation(libs.robolectric)
     testImplementation(libs.truth)
     testImplementation("androidx.fragment:fragment-testing:1.2.3")
-    testImplementation project(":car:app:app-testing")
+    testImplementation(project(":car:app:app-testing"))
 }
 
 android {
diff --git a/car/app/app-projected/build.gradle b/car/app/app-projected/build.gradle
index 557acde..746b59a 100644
--- a/car/app/app-projected/build.gradle
+++ b/car/app/app-projected/build.gradle
@@ -43,7 +43,7 @@
     testImplementation(libs.mockitoCore4)
     testImplementation(libs.robolectric)
     testImplementation(libs.truth)
-    testImplementation project(":car:app:app-testing")
+    testImplementation(project(":car:app:app-testing"))
 }
 
 android {
diff --git a/car/app/app-samples/navigation/common/build.gradle b/car/app/app-samples/navigation/common/build.gradle
index 5a4c90d..b3be643 100644
--- a/car/app/app-samples/navigation/common/build.gradle
+++ b/car/app/app-samples/navigation/common/build.gradle
@@ -40,7 +40,7 @@
     implementation(project(":car:app:app"))
 
     implementation("androidx.core:core:1.7.0")
-    implementation project(":annotation:annotation-experimental")
+    implementation(project(":annotation:annotation-experimental"))
     implementation("androidx.lifecycle:lifecycle-livedata:2.3.1")
     implementation("androidx.activity:activity:1.2.3")
 }
diff --git a/car/app/app-samples/navigation/common/src/main/java/androidx/car/app/sample/navigation/common/car/MicrophoneRecorder.java b/car/app/app-samples/navigation/common/src/main/java/androidx/car/app/sample/navigation/common/car/MicrophoneRecorder.java
index 8734b4f..a8144f0 100644
--- a/car/app/app-samples/navigation/common/src/main/java/androidx/car/app/sample/navigation/common/car/MicrophoneRecorder.java
+++ b/car/app/app-samples/navigation/common/src/main/java/androidx/car/app/sample/navigation/common/car/MicrophoneRecorder.java
@@ -27,7 +27,6 @@
 import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE;
 import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_SAMPLING_RATE;
 
-import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.pm.PackageManager;
 import android.media.AudioAttributes;
@@ -87,7 +86,6 @@
         recordingThread.start();
     }
 
-    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
     @RequiresPermission(RECORD_AUDIO)
     private void play(AudioFocusRequest audioFocusRequest) {
         if (SDK_INT < VERSION_CODES.O) {
@@ -135,7 +133,6 @@
                 audioFocusRequest);
     }
 
-    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
     @RequiresPermission(RECORD_AUDIO)
     private void doRecord(CarAudioRecord record) {
         if (SDK_INT < VERSION_CODES.O) {
diff --git a/car/app/app-samples/showcase/common/build.gradle b/car/app/app-samples/showcase/common/build.gradle
index c06017e..195e8bd 100644
--- a/car/app/app-samples/showcase/common/build.gradle
+++ b/car/app/app-samples/showcase/common/build.gradle
@@ -43,7 +43,7 @@
 
     implementation(libs.kotlinStdlibCommon)
     implementation("androidx.core:core:1.7.0")
-    implementation project(":annotation:annotation-experimental")
+    implementation(project(":annotation:annotation-experimental"))
 }
 
 
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java
index 45dad85..093b4ff 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/audio/VoiceInteraction.java
@@ -27,7 +27,6 @@
 import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE;
 import static androidx.car.app.media.CarAudioRecord.AUDIO_CONTENT_SAMPLING_RATE;
 
-import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.pm.PackageManager;
 import android.media.AudioAttributes;
@@ -69,7 +68,6 @@
      * Starts recording the car microphone, then plays it back.
      */
     @RequiresPermission(RECORD_AUDIO)
-    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
     public void voiceInteractionDemo() {
         // Some of the functions for recording require API level 26, so verify that first
         if (SDK_INT < VERSION_CODES.O) {
@@ -93,7 +91,6 @@
      */
     @RequiresApi(api = VERSION_CODES.O)
     @RequiresPermission(RECORD_AUDIO)
-    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
     public @NonNull Thread createRecordingThread() {
         Thread recordingThread = new Thread(
                 () -> {
@@ -183,7 +180,6 @@
     }
 
     @RequiresApi(api = VERSION_CODES.O)
-    @SuppressLint("ClassVerificationFailure") // runtime check for < API 26
     @RequiresPermission(RECORD_AUDIO)
     private void recordAudio(CarAudioRecord record) {
 
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/NavigationNotificationsDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/NavigationNotificationsDemoScreen.java
index fc10361..9390e7e 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/NavigationNotificationsDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/NavigationNotificationsDemoScreen.java
@@ -43,7 +43,7 @@
 
     // Suppressing 'ObsoleteSdkInt' as this code is shared between APKs with different min SDK
     // levels
-    @SuppressLint({"ObsoleteSdkInt", "ClassVerificationFailure"})
+    @SuppressLint("ObsoleteSdkInt")
     @Override
     public @NonNull Template onGetTemplate() {
         ItemList.Builder listBuilder = new ItemList.Builder();
diff --git a/car/app/app/build.gradle b/car/app/app/build.gradle
index c262bae..f1b3cd5 100644
--- a/car/app/app/build.gradle
+++ b/car/app/app/build.gradle
@@ -80,7 +80,7 @@
     testImplementation(libs.mockitoKotlin4)
     testImplementation(libs.robolectric)
     testImplementation(libs.truth)
-    testImplementation project(":car:app:app-testing")
+    testImplementation(project(":car:app:app-testing"))
 }
 
 project.ext {
diff --git a/compose/animation/animation-core/benchmark/build.gradle b/compose/animation/animation-core/benchmark/build.gradle
index 838f0fe..e9ef268 100644
--- a/compose/animation/animation-core/benchmark/build.gradle
+++ b/compose/animation/animation-core/benchmark/build.gradle
@@ -24,10 +24,10 @@
 
 dependencies {
 
-    androidTestImplementation project(":compose:animation:animation-core")
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
+    androidTestImplementation(project(":compose:animation:animation-core"))
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(libs.kotlinTestCommon)
diff --git a/compose/animation/animation-core/build.gradle b/compose/animation/animation-core/build.gradle
index 4a6cdf1..ad24266 100644
--- a/compose/animation/animation-core/build.gradle
+++ b/compose/animation/animation-core/build.gradle
@@ -137,7 +137,7 @@
 }
 
 dependencies {
-    lintPublish project(":compose:animation:animation-core-lint")
+    lintPublish(project(":compose:animation:animation-core-lint"))
 }
 
 androidx {
diff --git a/compose/animation/animation/build.gradle b/compose/animation/animation/build.gradle
index 56fd517..ab61e25 100644
--- a/compose/animation/animation/build.gradle
+++ b/compose/animation/animation/build.gradle
@@ -110,7 +110,7 @@
                 implementation(libs.truth)
                 implementation(libs.leakcanary)
                 implementation(libs.leakcanaryInstrumentation)
-                implementation("androidx.compose.foundation:foundation:1.2.1")
+                implementation(project(":compose:foundation:foundation"))
                 implementation(project(":compose:material3:material3"))
                 implementation("androidx.compose.ui:ui-test-junit4:1.2.1")
                 implementation(project(":compose:test-utils"))
@@ -129,7 +129,7 @@
 }
 
 dependencies {
-    lintPublish project(":compose:animation:animation-lint")
+    lintPublish(project(":compose:animation:animation-lint"))
 }
 
 androidx {
diff --git a/compose/animation/animation/integration-tests/animation-demos/build.gradle b/compose/animation/animation/integration-tests/animation-demos/build.gradle
index 5aaa416..014f1a5 100644
--- a/compose/animation/animation/integration-tests/animation-demos/build.gradle
+++ b/compose/animation/animation/integration-tests/animation-demos/build.gradle
@@ -31,8 +31,8 @@
     implementation(project(":compose:material:material"))
     implementation("androidx.compose.material:material-icons-core:1.6.7")
     implementation(project(":compose:ui:ui-tooling-preview"))
-    implementation project(":compose:material3:material3")
-    implementation project(":navigation:navigation-compose")
+    implementation(project(":compose:material3:material3"))
+    implementation(project(":navigation:navigation-compose"))
     implementation(project(":compose:ui:ui-tooling"))
 }
 
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsInLazyGrid.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsInLazyGrid.kt
new file mode 100644
index 0000000..9878d2a
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsInLazyGrid.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 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 androidx.compose.animation.demos.lookahead
+
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.animateBounds
+import androidx.compose.animation.demos.layoutanimation.turquoiseColors
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Preview
+@Composable
+fun AnimateBoundsInLazyGrid() {
+    val width by
+        produceState(300.dp) {
+            while (true) {
+                delay(1000)
+                // Toggle between 300.dp and 400.dp every 1000ms
+                value = (700 - value.value).dp
+            }
+        }
+    LookaheadScope {
+        LazyVerticalGrid(
+            GridCells.Adaptive(160.dp),
+            Modifier.padding(3.dp).fillMaxHeight().width(width).border(2.dp, Color.Blue)
+        ) {
+            items(40, key = { it }) { id ->
+                Box(
+                    Modifier.animateBounds(this@LookaheadScope)
+                        .animateItem()
+                        .padding(10.dp)
+                        .background(
+                            turquoiseColors[id % turquoiseColors.size],
+                            RoundedCornerShape(10.dp)
+                        )
+                        .fillMaxWidth()
+                        .aspectRatio(1f)
+                )
+            }
+        }
+    }
+}
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt
index 2b199c0..0e42b63 100644
--- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt
+++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt
@@ -24,13 +24,18 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 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.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.movableContentWithReceiverOf
 import androidx.compose.runtime.mutableStateOf
@@ -45,12 +50,15 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.LookaheadScope
 import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.findRootCoordinates
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.positionInParent
 import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.round
 import androidx.compose.ui.util.fastRoundToInt
 import androidx.compose.ui.util.lerp
@@ -453,6 +461,109 @@
             assertEquals(IntSize(itemBSizePx, itemBSizePx), boxSize)
         }
 
+    /**
+     * Test that animateBounds constrains its returned measure size to the incoming constraints, so
+     * that parent layout does not center it.
+     */
+    @Test
+    fun animateBounds_inLazyLayout() {
+        var width by mutableStateOf(200.dp)
+        val positions = mutableListOf<Offset>()
+        var size: IntSize = IntSize.Zero
+        rule.setContent {
+            CompositionLocalProvider(LocalDensity provides androidx.compose.ui.unit.Density(1f)) {
+                LookaheadScope {
+                    LazyVerticalGrid(GridCells.Fixed(2), Modifier.fillMaxHeight().width(width)) {
+                        items(5, key = { it }) { id ->
+                            Box(
+                                Modifier.animateBounds(this@LookaheadScope)
+                                    .then(
+                                        if (id == 0) {
+                                            Modifier.onGloballyPositioned {
+                                                positions.add(it.positionInRoot())
+                                                size = it.size
+                                            }
+                                        } else Modifier
+                                    )
+                                    .fillMaxWidth()
+                                    .aspectRatio(1f)
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        rule.waitForIdle()
+        rule.mainClock.autoAdvance = false
+        width = 400.dp
+        assertEquals(IntSize(100, 100), size)
+
+        while (size != IntSize(200, 200)) {
+            rule.mainClock.advanceTimeByFrame()
+            rule.waitForIdle()
+            assertEquals(Offset(0f, 0f), positions.last())
+        }
+    }
+
+    /**
+     * Test that items in LazyGrid keeps a consistent MFR throughout item node replacement triggered
+     * by animation.
+     */
+    @Test
+    fun motionFrameOfReferenceOfItemsInLazyGrid() {
+        var width by mutableStateOf(200.dp)
+        val MFRs = mutableListOf<Offset>()
+        var size: IntSize = IntSize.Zero
+        rule.setContent {
+            CompositionLocalProvider(LocalDensity provides androidx.compose.ui.unit.Density(1f)) {
+                LookaheadScope {
+                    LazyVerticalGrid(GridCells.Fixed(2), Modifier.fillMaxHeight().width(width)) {
+                        items(5, key = { it }) { id ->
+                            Box(
+                                Modifier.animateBounds(this@LookaheadScope)
+                                    .then(
+                                        if (id == 1) {
+                                            Modifier.onGloballyPositioned {
+                                                val MFR =
+                                                    it.findRootCoordinates()
+                                                        .localPositionOf(
+                                                            it,
+                                                            includeMotionFrameOfReference = true
+                                                        ) -
+                                                        it.findRootCoordinates()
+                                                            .localPositionOf(
+                                                                it,
+                                                                includeMotionFrameOfReference =
+                                                                    false
+                                                            )
+                                                MFRs.add(MFR)
+                                                size = it.size
+                                            }
+                                        } else Modifier
+                                    )
+                                    .fillMaxWidth()
+                                    .aspectRatio(1f)
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        rule.waitForIdle()
+        rule.mainClock.autoAdvance = false
+        width = 400.dp
+        assertEquals(IntSize(100, 100), size)
+        MFRs.clear()
+
+        while (size != IntSize(200, 200)) {
+            rule.mainClock.advanceTimeByFrame()
+            rule.waitForIdle()
+
+            // This MFR should never change until the next width change
+            assertEquals(Offset(200f, 0f), MFRs.last())
+        }
+    }
+
     @Test
     fun animateBounds_scrollBehavior() =
         with(rule.density) {
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
index c02221fe..0593207 100644
--- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
+++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
@@ -76,6 +76,7 @@
 import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.layout.ScaleFactor
 import androidx.compose.ui.layout.SubcomposeLayout
 import androidx.compose.ui.layout.approachLayout
@@ -2896,6 +2897,27 @@
         rule.mainClock.advanceTimeByFrame()
         rule.waitForIdle()
     }
+
+    @Test
+    fun intrinsicsQueryComingFromAboveLookaheadRoot() {
+        var intrinsicWidth = 0
+        rule.setContent {
+            CompositionLocalProvider(LocalDensity provides Density(1f)) {
+                Layout(
+                    content = {
+                        SharedTransitionLayout { Box(Modifier.skipToLookaheadSize().size(100.dp)) }
+                    },
+                ) { measurables, constraints ->
+                    val measurable = measurables[0]
+                    intrinsicWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
+                    val placeable = measurable.measure(constraints)
+                    layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, 0) }
+                }
+            }
+        }
+        rule.waitForIdle()
+        assertEquals(100, intrinsicWidth)
+    }
 }
 
 private fun assertEquals(a: IntSize, b: IntSize, delta: IntSize) {
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt
index 388b244..49ebf4c 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt
@@ -44,6 +44,7 @@
 import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.constrain
 import androidx.compose.ui.unit.round
 import androidx.compose.ui.unit.roundToIntSize
 import androidx.compose.ui.unit.toSize
@@ -196,6 +197,8 @@
         (animatedSize: IntSize, constraints: Constraints) -> Constraints,
     var animateMotionFrameOfReference: Boolean,
 ) : ApproachLayoutModifierNode, Modifier.Node() {
+
+    private var directManipulationParentsDirty = true
     private val boundsAnimation = BoundsTransformDeferredAnimation()
 
     override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
@@ -205,6 +208,10 @@
         return !boundsAnimation.isIdle
     }
 
+    override fun onAttach() {
+        directManipulationParentsDirty = true
+    }
+
     override fun Placeable.PlacementScope.isPlacementApproachInProgress(
         lookaheadCoordinates: LayoutCoordinates
     ): Boolean {
@@ -213,9 +220,11 @@
             lookaheadScope = lookaheadScope,
             placementScope = this,
             coroutineScope = coroutineScope,
+            directManipulationParentsDirty = directManipulationParentsDirty,
             includeMotionFrameOfReference = animateMotionFrameOfReference,
             boundsTransform = boundsTransform,
         )
+        directManipulationParentsDirty = animateMotionFrameOfReference
         return !boundsAnimation.isIdle
     }
 
@@ -237,7 +246,13 @@
         val chosenConstraints = onChooseMeasureConstraints(animatedSize, constraints)
 
         val placeable = measurable.measure(chosenConstraints)
-        return layout(animatedSize.width, animatedSize.height) {
+        // Constrain the animated size to the chosen constraints. This is important, particularly
+        // for the outer AnimateBoundsModifierElement, because we want to avoid parent layout
+        // placing this node in its center due to the non-conforming size. In that scenario, we'd
+        // have no guarantee that the parent's center-placement is animated, esp if the
+        // parent layout places this node using `withMotionFrameOfReferencePlacement`.
+        val (w, h) = chosenConstraints.constrain(animatedSize)
+        return layout(w, h) {
             val animatedBounds = boundsAnimation.value
             val positionInScope =
                 with(lookaheadScope) {
@@ -249,13 +264,12 @@
                         )
                     }
                 }
-
             val topLeft =
                 if (animatedBounds != null) {
                     boundsAnimation.updateCurrentBounds(animatedBounds.topLeft, animatedBounds.size)
                     animatedBounds.topLeft
                 } else {
-                    boundsAnimation.currentBounds?.topLeft ?: Offset.Zero
+                    (boundsAnimation.currentBounds?.topLeft ?: Offset.Zero)
                 }
             val (x, y) = positionInScope?.let { topLeft - it } ?: Offset.Zero
             placeable.place(x.fastRoundToInt(), y.fastRoundToInt())
@@ -339,6 +353,7 @@
         lookaheadScope: LookaheadScope,
         placementScope: Placeable.PlacementScope,
         coroutineScope: CoroutineScope,
+        directManipulationParentsDirty: Boolean,
         includeMotionFrameOfReference: Boolean,
         boundsTransform: BoundsTransform,
     ) {
@@ -347,7 +362,7 @@
                 val lookaheadScopeCoordinates = placementScope.lookaheadScopeCoordinates
 
                 var delta = Offset.Zero
-                if (!includeMotionFrameOfReference) {
+                if (!includeMotionFrameOfReference && directManipulationParentsDirty) {
                     // As the Layout changes, we need to keep track of the accumulated offset up
                     // the hierarchy tree, to get the proper Offset accounting for scrolling.
                     val parents = directManipulationParents ?: mutableListOf()
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
index d3d8248..779033d 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
@@ -65,30 +65,17 @@
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.graphics.addOutline
 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
 import androidx.compose.ui.graphics.drawscope.DrawScope
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.LookaheadScope
-import androidx.compose.ui.layout.Measurable
-import androidx.compose.ui.layout.MeasureResult
-import androidx.compose.ui.layout.MeasureScope
-import androidx.compose.ui.layout.ScaleFactor
 import androidx.compose.ui.layout.layout
-import androidx.compose.ui.node.LayoutModifierNode
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.unit.constrain
-import androidx.compose.ui.unit.toSize
 import androidx.compose.ui.util.fastForEach
-import kotlin.math.roundToInt
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.launch
 
@@ -1150,91 +1137,6 @@
     }
 }
 
-private val DefaultEnabled: () -> Boolean = { true }
-
-@OptIn(ExperimentalSharedTransitionApi::class)
-private fun Modifier.createContentScaleModifier(
-    scaleToBounds: ScaleToBoundsImpl,
-    isEnabled: () -> Boolean
-): Modifier =
-    this.then(
-        if (scaleToBounds.contentScale == ContentScale.Crop) {
-            Modifier.graphicsLayer { clip = isEnabled() }
-        } else Modifier
-    ) then SkipToLookaheadElement(scaleToBounds, isEnabled)
-
-@OptIn(ExperimentalSharedTransitionApi::class)
-private data class SkipToLookaheadElement(
-    val scaleToBounds: ScaleToBoundsImpl? = null,
-    val isEnabled: () -> Boolean = DefaultEnabled,
-) : ModifierNodeElement<SkipToLookaheadNode>() {
-    override fun create(): SkipToLookaheadNode {
-        return SkipToLookaheadNode(scaleToBounds, isEnabled)
-    }
-
-    override fun update(node: SkipToLookaheadNode) {
-        node.scaleToBounds = scaleToBounds
-        node.isEnabled = isEnabled
-    }
-
-    override fun InspectorInfo.inspectableProperties() {
-        name = "skipToLookahead"
-        properties["scaleToBounds"] = scaleToBounds
-        properties["isEnabled"] = isEnabled
-    }
-}
-
-@OptIn(ExperimentalSharedTransitionApi::class)
-private class SkipToLookaheadNode(scaleToBounds: ScaleToBoundsImpl?, isEnabled: () -> Boolean) :
-    LayoutModifierNode, Modifier.Node() {
-    var lookaheadConstraints: Constraints? = null
-    var scaleToBounds: ScaleToBoundsImpl? by mutableStateOf(scaleToBounds)
-    var isEnabled: () -> Boolean by mutableStateOf(isEnabled)
-
-    override fun MeasureScope.measure(
-        measurable: Measurable,
-        constraints: Constraints
-    ): MeasureResult {
-        if (isLookingAhead) {
-            lookaheadConstraints = constraints
-        }
-        val p = measurable.measure(lookaheadConstraints!!)
-        val contentSize = IntSize(p.width, p.height)
-        val constrainedSize = constraints.constrain(contentSize)
-        return layout(constrainedSize.width, constrainedSize.height) {
-            val scaleToBounds = scaleToBounds
-            if (!isEnabled() || scaleToBounds == null) {
-                p.place(0, 0)
-            } else {
-                val contentScale = scaleToBounds.contentScale
-                val resolvedScale =
-                    if (contentSize.width == 0 || contentSize.height == 0) {
-                        ScaleFactor(1f, 1f)
-                    } else
-                        contentScale.computeScaleFactor(
-                            contentSize.toSize(),
-                            constrainedSize.toSize()
-                        )
-
-                val (x, y) =
-                    scaleToBounds.alignment.align(
-                        IntSize(
-                            (contentSize.width * resolvedScale.scaleX).roundToInt(),
-                            (contentSize.height * resolvedScale.scaleY).roundToInt()
-                        ),
-                        constrainedSize,
-                        layoutDirection
-                    )
-                p.placeWithLayer(x, y) {
-                    scaleX = resolvedScale.scaleX
-                    scaleY = resolvedScale.scaleY
-                    transformOrigin = TransformOrigin(0f, 0f)
-                }
-            }
-        }
-    }
-}
-
 internal interface LayerRenderer {
     val parentState: SharedElementInternalState?
 
@@ -1312,7 +1214,7 @@
 
 @Immutable
 @ExperimentalSharedTransitionApi
-private class ScaleToBoundsImpl(val contentScale: ContentScale, val alignment: Alignment) :
+internal class ScaleToBoundsImpl(val contentScale: ContentScale, val alignment: Alignment) :
     ResizeMode
 
 @ExperimentalSharedTransitionApi private object RemeasureImpl : ResizeMode
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SkipToLookaheadNode.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SkipToLookaheadNode.kt
new file mode 100644
index 0000000..f8d6aa4
--- /dev/null
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SkipToLookaheadNode.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2024 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 androidx.compose.animation
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.IntrinsicMeasurable
+import androidx.compose.ui.layout.IntrinsicMeasureScope
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.constrain
+import androidx.compose.ui.unit.toSize
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+internal class SkipToLookaheadNode(scaleToBounds: ScaleToBoundsImpl?, isEnabled: () -> Boolean) :
+    LayoutModifierNode, Modifier.Node() {
+    var scaleToBounds: ScaleToBoundsImpl? by mutableStateOf(scaleToBounds)
+    var isEnabled: () -> Boolean by mutableStateOf(isEnabled)
+
+    private var lookaheadConstraints: Constraints? = null
+    private var lookaheadSize: IntSize = InvalidSize
+
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints
+    ): MeasureResult {
+        if (isLookingAhead) {
+            lookaheadConstraints = constraints
+        }
+        val p = measurable.measure(lookaheadConstraints!!)
+        lookaheadSize = IntSize(p.width, p.height)
+        val constrainedSize = constraints.constrain(lookaheadSize)
+        return layout(constrainedSize.width, constrainedSize.height) {
+            val scaleToBounds = scaleToBounds
+            if (!isEnabled() || scaleToBounds == null) {
+                p.place(0, 0)
+            } else {
+                val contentScale = scaleToBounds.contentScale
+                val resolvedScale =
+                    if (lookaheadSize.width == 0 || lookaheadSize.height == 0) {
+                        ScaleFactor(1f, 1f)
+                    } else
+                        contentScale.computeScaleFactor(
+                            lookaheadSize.toSize(),
+                            constrainedSize.toSize()
+                        )
+
+                val (x, y) =
+                    scaleToBounds.alignment.align(
+                        IntSize(
+                            (lookaheadSize.width * resolvedScale.scaleX).roundToInt(),
+                            (lookaheadSize.height * resolvedScale.scaleY).roundToInt()
+                        ),
+                        constrainedSize,
+                        layoutDirection
+                    )
+                p.placeWithLayer(x, y) {
+                    scaleX = resolvedScale.scaleX
+                    scaleY = resolvedScale.scaleY
+                    transformOrigin = TransformOrigin(0f, 0f)
+                }
+            }
+        }
+    }
+
+    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int
+    ): Int {
+        // If lookahead has already occurred, return the lookahead width/height to skip propagating
+        // the call further, and ensure convergence with lookahead.
+        return if (!isLookingAhead && lookaheadSize.isValid) {
+            lookaheadSize.width
+        } else {
+            measurable.maxIntrinsicWidth(height)
+        }
+    }
+
+    override fun IntrinsicMeasureScope.minIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int
+    ): Int {
+        // If lookahead has already occurred, return the lookahead width/height to skip propagating
+        // the call further, and ensure convergence with lookahead.
+        return if (!isLookingAhead && lookaheadSize.isValid) {
+            lookaheadSize.width
+        } else {
+            measurable.minIntrinsicWidth(height)
+        }
+    }
+
+    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int
+    ): Int {
+        // If lookahead has already occurred, return the lookahead width/height to skip propagating
+        // the call further, and ensure convergence with lookahead.
+        return if (!isLookingAhead && lookaheadSize.isValid) {
+            lookaheadSize.height
+        } else {
+            measurable.maxIntrinsicHeight(width)
+        }
+    }
+
+    override fun IntrinsicMeasureScope.minIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int
+    ): Int {
+        // If lookahead has already occurred, return the lookahead width/height to skip propagating
+        // the call further, and ensure convergence with lookahead.
+        return if (!isLookingAhead && lookaheadSize.isValid) {
+            lookaheadSize.height
+        } else {
+            measurable.minIntrinsicHeight(width)
+        }
+    }
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+internal data class SkipToLookaheadElement(
+    val scaleToBounds: ScaleToBoundsImpl? = null,
+    val isEnabled: () -> Boolean = DefaultEnabled,
+) : ModifierNodeElement<SkipToLookaheadNode>() {
+    override fun create(): SkipToLookaheadNode {
+        return SkipToLookaheadNode(scaleToBounds, isEnabled)
+    }
+
+    override fun update(node: SkipToLookaheadNode) {
+        node.scaleToBounds = scaleToBounds
+        node.isEnabled = isEnabled
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "skipToLookahead"
+        properties["scaleToBounds"] = scaleToBounds
+        properties["isEnabled"] = isEnabled
+    }
+}
+
+private val DefaultEnabled: () -> Boolean = { true }
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+internal fun Modifier.createContentScaleModifier(
+    scaleToBounds: ScaleToBoundsImpl,
+    isEnabled: () -> Boolean
+): Modifier =
+    this.then(
+        if (scaleToBounds.contentScale == ContentScale.Crop) {
+            Modifier.graphicsLayer { clip = isEnabled() }
+        } else Modifier
+    ) then SkipToLookaheadElement(scaleToBounds, isEnabled)
diff --git a/compose/benchmark-utils/benchmark/build.gradle b/compose/benchmark-utils/benchmark/build.gradle
index 94968c5..0ab9324 100644
--- a/compose/benchmark-utils/benchmark/build.gradle
+++ b/compose/benchmark-utils/benchmark/build.gradle
@@ -23,10 +23,10 @@
 }
 
 dependencies {
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:foundation:foundation-layout")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:foundation:foundation-layout"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(libs.kotlinTestCommon)
diff --git a/compose/foundation/foundation-layout/benchmark/build.gradle b/compose/foundation/foundation-layout/benchmark/build.gradle
index 3146ce7..2b41dc0 100644
--- a/compose/foundation/foundation-layout/benchmark/build.gradle
+++ b/compose/foundation/foundation-layout/benchmark/build.gradle
@@ -24,12 +24,12 @@
 
 dependencies {
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:foundation:foundation-layout")
-    androidTestImplementation project(":compose:material:material")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:foundation:foundation-layout"))
+    androidTestImplementation(project(":compose:material:material"))
     androidTestImplementation("androidx.compose.material:material-icons-core:1.6.7")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/compose/foundation/foundation/benchmark/build.gradle b/compose/foundation/foundation/benchmark/build.gradle
index 254ee75..7fdab92 100644
--- a/compose/foundation/foundation/benchmark/build.gradle
+++ b/compose/foundation/foundation/benchmark/build.gradle
@@ -33,13 +33,13 @@
 }
 
 dependencies {
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:ui:ui-text:ui-text-benchmark")
-    androidTestImplementation project(":compose:foundation:foundation-layout")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:material:material")
-    androidTestImplementation project(":compose:benchmark-utils")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-text:ui-text-benchmark"))
+    androidTestImplementation(project(":compose:foundation:foundation-layout"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:material:material"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/compose/foundation/foundation/build.gradle b/compose/foundation/foundation/build.gradle
index d35b2d5..0d84d31 100644
--- a/compose/foundation/foundation/build.gradle
+++ b/compose/foundation/foundation/build.gradle
@@ -138,11 +138,8 @@
     lintPublish(project(":compose:foundation:foundation-lint"))
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/foundation/foundation"
     namespace = "androidx.compose.foundation"
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
@@ -160,4 +157,5 @@
     metalavaK2UastEnabled = false
     samples(project(":compose:foundation:foundation:foundation-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeLineHeight.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeLineHeight.kt
index 5e49dd7e..0306cd0 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeLineHeight.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeLineHeight.kt
@@ -29,7 +29,6 @@
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.selection.selectable
 import androidx.compose.foundation.selection.selectableGroup
-import androidx.compose.foundation.text.input.TextFieldState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.Checkbox
 import androidx.compose.material.RadioButton
@@ -51,6 +50,7 @@
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.style.LineHeightStyle
 import androidx.compose.ui.text.style.LineHeightStyle.Trim
 import androidx.compose.ui.text.style.TextOverflow
@@ -443,10 +443,11 @@
         Spacer(Modifier.padding(16.dp))
 
         Column(Modifier.width(width)) {
-            val state = remember(text) { TextFieldState(text.text) }
+            var textFieldValue by remember(text) { mutableStateOf(TextFieldValue(text)) }
 
             TextFieldWithMetrics(
-                state = state,
+                value = textFieldValue,
+                onValueChange = { textFieldValue = it },
                 style = style,
                 maxLines = maxLines,
                 softWrap = !singleLine
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemoMetrics.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemoMetrics.kt
index 18734fd..a6a0d3c 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemoMetrics.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemoMetrics.kt
@@ -21,8 +21,6 @@
 import androidx.compose.foundation.demos.text.TextMetricHelper.Alignment.Left
 import androidx.compose.foundation.demos.text.TextMetricHelper.Alignment.Right
 import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.input.TextFieldLineLimits
-import androidx.compose.foundation.text.input.TextFieldState
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -43,6 +41,7 @@
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
@@ -71,7 +70,8 @@
 
 @Composable
 internal fun TextFieldWithMetrics(
-    state: TextFieldState,
+    value: TextFieldValue,
+    onValueChange: (TextFieldValue) -> Unit,
     style: TextStyle,
     maxLines: Int,
     softWrap: Boolean = true,
@@ -80,16 +80,13 @@
     var textLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
 
     BasicTextField(
-        state = state,
+        value = value,
+        onValueChange = onValueChange,
         modifier = Modifier.drawTextMetrics(textLayout, colors).background(Color.White),
         textStyle = style,
-        lineLimits =
-            if (!softWrap) {
-                TextFieldLineLimits.SingleLine
-            } else {
-                TextFieldLineLimits.MultiLine(maxHeightInLines = maxLines)
-            },
-        onTextLayout = { textLayout = it.invoke() }
+        singleLine = !softWrap,
+        maxLines = maxLines,
+        onTextLayout = { textLayout = it }
     )
 }
 
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/ReceiveContentDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/ReceiveContentDemos.kt
index 946920f..0ba79a5 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/ReceiveContentDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/ReceiveContentDemos.kt
@@ -334,7 +334,7 @@
     }
 }
 
-@Suppress("ClassVerificationFailure", "DEPRECATION")
+@Suppress("DEPRECATION")
 private fun Uri.readImageBitmap(context: Context): ImageBitmap? {
     return try {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt
index 47dadf2..d93aaed 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt
@@ -38,7 +38,6 @@
 import androidx.compose.ui.focus.FocusDirection.Companion.Next
 import androidx.compose.ui.focus.FocusDirection.Companion.Previous
 import androidx.compose.ui.focus.FocusManager
-import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalFocusManager
 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
@@ -112,15 +111,14 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
-                Spacer(Modifier.size(90.toDp()))
-                TestFocusable(size = 10.toDp())
+                // Put a focusable at the end of the viewport.
+                WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
         requestFocusAndScrollToTop()
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(90.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 90.toDp())
             .assertIsDisplayed()
             .assertIsFocused()
 
@@ -129,7 +127,7 @@
 
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(40.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 40.toDp())
             .assertIsDisplayed()
     }
 
@@ -139,15 +137,14 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
-                Spacer(Modifier.size(90.toDp()))
-                TestFocusable(size = 10.toDp())
+                // Put a focusable at the end of the viewport.
+                WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
         requestFocusAndScrollToTop()
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(90.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 90.toDp())
             .assertIsDisplayed()
             .assertIsFocused()
 
@@ -156,7 +153,7 @@
 
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(85.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 85.toDp())
             .assertIsDisplayed()
     }
 
@@ -166,7 +163,7 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
+                // Put a focusable at the end of the viewport.
                 WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
@@ -228,9 +225,8 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
-                Spacer(Modifier.size(90.toDp()))
-                TestFocusable(size = 10.toDp())
+                // Put a focusable at the end of the viewport.
+                WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
 
             if (animate) {
@@ -247,7 +243,7 @@
         requestFocusAndScrollToTop()
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(90.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 90.toDp())
             .assertIsDisplayed()
             .assertIsFocused()
 
@@ -255,7 +251,7 @@
 
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(30.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 30.toDp())
             .assertIsDisplayed()
     }
 
@@ -265,15 +261,14 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
-                Spacer(Modifier.size(90.toDp()))
-                TestFocusable(size = 10.toDp())
+                // Put a focusable at the end of the viewport.
+                WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
         requestFocusAndScrollToTop()
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(90.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 90.toDp())
             .assertIsDisplayed()
             .assertIsFocused()
 
@@ -284,10 +279,9 @@
         rule.mainClock.advanceTimeByFrame()
         rule.waitForIdle()
 
-        // Interrupt the scroll by manually dragging.
+        // Interrupt the scroll.
         rule.onNodeWithTag(scrollableAreaTag).performTouchInput {
             down(center)
-            moveBy(Offset(viewConfiguration.touchSlop + 1, viewConfiguration.touchSlop + 1))
             up()
         }
 
@@ -311,15 +305,14 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
-                Spacer(Modifier.size(90.toDp()))
-                TestFocusable(size = 10.toDp())
+                // Put a focusable at the end of the viewport.
+                WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
         requestFocusAndScrollToTop()
         rule
             .onNodeWithTag(focusableTag)
-            .assertScrollAxisPositionInRootIsEqualTo(90.toDp())
+            .assertScrollAxisPositionInRootIsEqualTo(if (reverseScrolling) 0.toDp() else 90.toDp())
             .assertIsDisplayed()
             .assertIsFocused()
 
@@ -330,10 +323,9 @@
         rule.mainClock.advanceTimeByFrame()
         rule.waitForIdle()
 
-        // Interrupt the scroll by manually dragging.
+        // Interrupt the scroll.
         rule.onNodeWithTag(scrollableAreaTag).performTouchInput {
             down(center)
-            moveBy(Offset(viewConfiguration.touchSlop + 1, viewConfiguration.touchSlop + 1))
             up()
         }
 
@@ -396,7 +388,7 @@
 
         rule.setContent {
             ScrollableRowOrColumn(size = viewportSize) {
-                // Put a focusable in the bottom of the viewport.
+                // Put a focusable at the end of the viewport.
                 WithSpacerBefore(size = 90.toDp()) { TestFocusable(size = 10.toDp()) }
             }
         }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSourceTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSourceTest.kt
new file mode 100644
index 0000000..1b8cac3
--- /dev/null
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSourceTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024 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 androidx.compose.foundation.draganddrop
+
+import android.os.Build
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixels
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Constraints
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@MediumTest
+class DragAndDropSourceTest {
+    @get:Rule val rule = createComposeRule()
+
+    /** Regression test for b/379682458 */
+    @Test
+    fun dragAndDropSource_doesNotPreventChildInvalidations() {
+        var moveBox by mutableStateOf(false)
+        val tag = "source"
+        rule.setContent {
+            with(LocalDensity.current) {
+                Box(
+                    Modifier.size(200.toDp())
+                        .testTag(tag)
+                        .dragAndDropSource { _ -> null }
+                        .clip(RectangleShape)
+                        .layout { measurable, constraints ->
+                            val placeable =
+                                measurable.measure(
+                                    constraints.copy(maxWidth = Constraints.Infinity)
+                                )
+                            layout(constraints.maxWidth, constraints.maxHeight) {
+                                placeable.place(x = if (moveBox) -200 else 0, y = 0)
+                            }
+                        }
+                        .width(400.toDp())
+                        .drawBehind {
+                            val halfWidth = 200f
+                            val halfWidthSize = Size(halfWidth, size.height)
+                            drawRect(Color.Blue, size = halfWidthSize)
+                            drawRect(
+                                Color.Red,
+                                topLeft = Offset(halfWidth, 0f),
+                                size = halfWidthSize
+                            )
+                        }
+                )
+            }
+        }
+
+        rule.onNodeWithTag(tag).captureToImage().assertPixels { Color.Blue }
+
+        // Make the layout move the child so that the red box is now visible
+        rule.runOnIdle { moveBox = true }
+
+        rule.onNodeWithTag(tag).captureToImage().assertPixels { Color.Red }
+    }
+}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
index 4981f7c..1182e61 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
@@ -19,15 +19,13 @@
 
 package androidx.compose.foundation.draganddrop
 
-import android.graphics.Picture
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.runtime.Immutable
 import androidx.compose.ui.draw.CacheDrawScope
 import androidx.compose.ui.draw.DrawResult
 import androidx.compose.ui.graphics.drawscope.DrawScope
-import androidx.compose.ui.graphics.drawscope.draw
-import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
-import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.layer.GraphicsLayer
+import androidx.compose.ui.graphics.layer.drawLayer
 
 @Immutable
 internal actual object DragAndDropSourceDefaults {
@@ -37,39 +35,24 @@
 }
 
 internal actual class CacheDrawScopeDragShadowCallback {
-    private var cachedPicture: Picture? = null
+    private var graphicsLayer: GraphicsLayer? = null
 
     actual fun drawDragShadow(drawScope: DrawScope) =
         with(drawScope) {
-            when (val picture = cachedPicture) {
+            when (val layer = graphicsLayer) {
                 null ->
                     throw IllegalArgumentException(
                         "No cached drag shadow. Check if Modifier.cacheDragShadow(painter) was called."
                     )
-                else -> drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
+                else -> {
+                    if (!layer.isReleased) drawLayer(layer)
+                }
             }
         }
 
     actual fun cachePicture(scope: CacheDrawScope): DrawResult =
         with(scope) {
-            val picture = Picture()
-            cachedPicture = picture
-            val width = this.size.width.toInt()
-            val height = this.size.height.toInt()
-            onDrawWithContent {
-                val pictureCanvas =
-                    androidx.compose.ui.graphics.Canvas(picture.beginRecording(width, height))
-                draw(
-                    density = this,
-                    layoutDirection = this.layoutDirection,
-                    canvas = pictureCanvas,
-                    size = this.size
-                ) {
-                    [email protected]()
-                }
-                picture.endRecording()
-
-                drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
-            }
+            graphicsLayer = scope.obtainGraphicsLayer().apply { record { drawContent() } }
+            onDrawWithContent { drawLayer(graphicsLayer!!) }
         }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
index 6c93970..e887c7e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
@@ -94,17 +94,21 @@
     private var focusedChild: LayoutCoordinates? = null
 
     /**
-     * The previous bounds of the [focusedChild] used by [onRemeasured] to calculate when the
-     * focused child is first clipped when scrolling is reversed.
-     */
-    private var focusedChildBoundsFromPreviousRemeasure: Rect? = null
-
-    /**
      * Set to true when this class is actively animating the scroll to keep the focused child in
      * view.
      */
     private var trackingFocusedChild = false
 
+    /**
+     * When the viewport shrinks, we need to wait for the focused bounds to be updated, which
+     * happens when globally positioned callbacks happen - this is _after_ this node has been
+     * remeasured. As a result we cannot bring into view until we know what the new bounds of the
+     * child are, as this can be affected by the measurement logic of this scrollable container /
+     * its parent(s). The old bounds of the child might still appear to be 'visible', even though it
+     * will now be placed in a different place, and become invisible.
+     */
+    private var childWasMaxVisibleBeforeViewportShrunk = false
+
     /** The size of the scrollable container. */
     internal var viewportSize = IntSize.Zero
         private set
@@ -141,41 +145,49 @@
 
     fun onFocusBoundsChanged(newBounds: LayoutCoordinates?) {
         focusedChild = newBounds
+
+        if (childWasMaxVisibleBeforeViewportShrunk) {
+            getFocusedChildBounds()?.let { focusedChild ->
+                if (DEBUG) println("[$TAG] focused child bounds: $focusedChild")
+                if (!focusedChild.isMaxVisible(viewportSize)) {
+                    if (DEBUG)
+                        println(
+                            "[$TAG] focused child was clipped by viewport shrink: $focusedChild"
+                        )
+                    trackingFocusedChild = true
+                    launchAnimation()
+                }
+            }
+        }
+        childWasMaxVisibleBeforeViewportShrunk = false
     }
 
     override fun onRemeasured(size: IntSize) {
-        val oldSize = viewportSize
+        val previousViewportSize = viewportSize
         viewportSize = size
 
         // Don't care if the viewport grew.
-        if (size >= oldSize) return
+        if (size >= previousViewportSize) return
 
-        if (DEBUG) println("[$TAG] viewport shrunk: $oldSize -> $size")
+        if (DEBUG) println("[$TAG] viewport shrunk: $previousViewportSize -> $size")
 
-        getFocusedChildBounds()?.let { focusedChild ->
-            if (DEBUG) println("[$TAG] focused child bounds: $focusedChild")
-            val previousFocusedChildBounds = focusedChildBoundsFromPreviousRemeasure ?: focusedChild
-            if (
-                !isAnimationRunning &&
-                    !trackingFocusedChild &&
-                    // Resize caused it to go from being fully visible to at least partially
-                    // clipped. Need to use the lastFocusedChildBounds to compare with the old size
-                    // only to handle the case where scrolling direction is reversed: in that case,
-                    // when
-                    // the child first goes out-of-bounds, it will be out of bounds regardless of
-                    // which
-                    // size we pass in, so the only way to detect the change is to use the previous
-                    // bounds.
-                    previousFocusedChildBounds.isMaxVisible(oldSize) &&
-                    !focusedChild.isMaxVisible(size)
-            ) {
-                if (DEBUG)
-                    println("[$TAG] focused child was clipped by viewport shrink: $focusedChild")
-                trackingFocusedChild = true
-                launchAnimation()
-            }
+        // Ignore if we are already tracking an existing animation
+        if (isAnimationRunning || trackingFocusedChild) {
+            if (DEBUG) println("[$TAG] ignoring size change because animation is in progress")
+            return
+        }
 
-            this.focusedChildBoundsFromPreviousRemeasure = focusedChild
+        // onRemeasured is called before the onGloballyPositioned callbacks are dispatched, so
+        // the bounds we get here will essentially be the previous bounds of the focused child,
+        // before this remeasurement occurred.
+        val boundsBeforeRemeasurement = getFocusedChildBounds() ?: return
+
+        // If the focused child was previously fully visible (its 'previous' bounds fit inside the
+        // previous viewport), we need to see if it is still visible after this remeasurement
+        // finishes and the child is placed in its new location. To do that we need to wait for
+        // its onGloballyPositioned callback, which will then end up calling onFocusBoundsChanged
+        if (boundsBeforeRemeasurement.isMaxVisible(previousViewportSize)) {
+            childWasMaxVisibleBeforeViewportShrunk = true
         }
     }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
index 194bcf9..fbc0c73 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
@@ -784,7 +784,7 @@
  *   `null` if all pointers are raised or the position change was consumed by another gesture
  *   detector.
  */
-private suspend inline fun AwaitPointerEventScope.awaitPointerSlopOrCancellation(
+internal suspend inline fun AwaitPointerEventScope.awaitPointerSlopOrCancellation(
     pointerId: PointerId,
     pointerType: PointerType,
     orientation: Orientation?,
@@ -836,7 +836,7 @@
  * new [PointerInputChange] one should add it to this detector using [addPointerInputChange]. If the
  * position change causes the touch slop to be crossed, [addPointerInputChange] will return true.
  */
-private class TouchSlopDetector(
+internal class TouchSlopDetector(
     val orientation: Orientation? = null,
     val initialPositionChange: Offset
 ) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt
index 73e957a..bbe9326 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt
@@ -275,8 +275,19 @@
                 // prefetch is best effort.
                 val prefetchHandles = mutableListOf<LazyLayoutPrefetchState.PrefetchHandle>()
                 Snapshot.withoutReadObservation {
-                    layoutInfoState.value.prefetchInfoRetriever(lineIndex).fastForEach {
-                        prefetchHandles.add(prefetchState.schedulePrefetch(it.first, it.second))
+                    val layoutInfo =
+                        if (hasLookaheadOccurred) {
+                            approachLayoutInfo
+                        } else {
+                            layoutInfoState.value
+                        }
+
+                    layoutInfo?.let {
+                        it.prefetchInfoRetriever(lineIndex).fastForEach { lineInfo ->
+                            prefetchHandles.add(
+                                prefetchState.schedulePrefetch(lineInfo.first, lineInfo.second)
+                            )
+                        }
                     }
                 }
                 return prefetchHandles
diff --git a/compose/integration-tests/material-catalog/build.gradle b/compose/integration-tests/material-catalog/build.gradle
index e2ab46c..ca73ac9 100644
--- a/compose/integration-tests/material-catalog/build.gradle
+++ b/compose/integration-tests/material-catalog/build.gradle
@@ -50,16 +50,16 @@
 
 dependencies {
     implementation(libs.kotlinStdlib)
-    implementation project(":compose:runtime:runtime")
-    implementation project(":compose:foundation:foundation-layout")
-    implementation project(":compose:ui:ui")
-    implementation project(":compose:material:material")
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:foundation:foundation-layout"))
+    implementation(project(":compose:ui:ui"))
+    implementation(project(":compose:material:material"))
     implementation("androidx.compose.material:material-icons-core:1.6.7")
-    implementation project(":compose:material3:material3")
-    implementation project(":compose:material:material:integration-tests:material-catalog")
-    implementation project(":compose:material3:material3:integration-tests:material3-catalog")
+    implementation(project(":compose:material3:material3"))
+    implementation(project(":compose:material:material:integration-tests:material-catalog"))
+    implementation(project(":compose:material3:material3:integration-tests:material3-catalog"))
     implementation "androidx.activity:activity-compose:1.3.1"
-    implementation project(":navigation:navigation-compose")
+    implementation(project(":navigation:navigation-compose"))
     // old version of common-java8 conflicts with newer version, because both have
     // DefaultLifecycleEventObserver.
     // Outside of androidx this is resolved via constraint added to lifecycle-common,
diff --git a/compose/material/material-navigation/api/current.txt b/compose/material/material-navigation/api/current.txt
index dd70def..f339d6c 100644
--- a/compose/material/material-navigation/api/current.txt
+++ b/compose/material/material-navigation/api/current.txt
@@ -2,7 +2,8 @@
 package androidx.compose.material.navigation {
 
   public final class BottomSheetKt {
-    method @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @Deprecated @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional boolean sheetGesturesEnabled, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
 
   @androidx.navigation.Navigator.Name("bottomSheet") public final class BottomSheetNavigator extends androidx.navigation.Navigator<androidx.compose.material.navigation.BottomSheetNavigator.Destination> {
diff --git a/compose/material/material-navigation/api/restricted_current.txt b/compose/material/material-navigation/api/restricted_current.txt
index 1dbe896..a024227 100644
--- a/compose/material/material-navigation/api/restricted_current.txt
+++ b/compose/material/material-navigation/api/restricted_current.txt
@@ -2,7 +2,8 @@
 package androidx.compose.material.navigation {
 
   public final class BottomSheetKt {
-    method @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @Deprecated @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void ModalBottomSheetLayout(androidx.compose.material.navigation.BottomSheetNavigator bottomSheetNavigator, optional androidx.compose.ui.Modifier modifier, optional boolean sheetGesturesEnabled, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
 
   @androidx.navigation.Navigator.Name("bottomSheet") public final class BottomSheetNavigator extends androidx.navigation.Navigator<androidx.compose.material.navigation.BottomSheetNavigator.Destination> {
diff --git a/compose/material/material-navigation/build.gradle b/compose/material/material-navigation/build.gradle
index 6d3b9f4..a80dfb7 100644
--- a/compose/material/material-navigation/build.gradle
+++ b/compose/material/material-navigation/build.gradle
@@ -31,7 +31,7 @@
     implementation(libs.kotlinStdlib)
     implementation(libs.kotlinSerializationCore)
 
-    androidTestImplementation project(":compose:test-utils")
+    androidTestImplementation(project(":compose:test-utils"))
     androidTestImplementation("androidx.navigation:navigation-testing:2.7.7")
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
     androidTestImplementation(project(":compose:ui:ui-test-manifest"))
diff --git a/compose/material/material-navigation/src/main/java/androidx/compose/material/navigation/BottomSheet.kt b/compose/material/material-navigation/src/main/java/androidx/compose/material/navigation/BottomSheet.kt
index c4e544f..873012c 100644
--- a/compose/material/material-navigation/src/main/java/androidx/compose/material/navigation/BottomSheet.kt
+++ b/compose/material/material-navigation/src/main/java/androidx/compose/material/navigation/BottomSheet.kt
@@ -45,6 +45,7 @@
  * @sample androidx.compose.material.navigation.samples.BottomSheetNavDemo
  * @see [ModalBottomSheetLayout]
  */
+@Deprecated("Maintained for binary compatibility", level = DeprecationLevel.HIDDEN)
 @Suppress("MissingJvmstatic")
 @Composable
 // Keep defaults in sync with androidx.compose.material.ModalBottomSheetLayout
@@ -70,3 +71,51 @@
         content = content
     )
 }
+
+/**
+ * Create a [ModalBottomSheetLayout] displaying content from a [BottomSheetNavigator].
+ *
+ * @param bottomSheetNavigator The navigator that manages the bottom sheet content.
+ * @param modifier Optional [Modifier] for the entire component.
+ * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures.
+ * @param sheetShape The shape of the bottom sheet.
+ * @param sheetElevation The elevation of the bottom sheet.
+ * @param sheetBackgroundColor The background color of the bottom sheet.
+ * @param sheetContentColor The preferred content color provided by the bottom sheet to its
+ *   children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not
+ *   a color from the theme, this will keep the same content color set above the bottom sheet.
+ * @param scrimColor The color of the scrim that is applied to the rest of the screen when the
+ *   bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
+ *   longer be applied and the bottom sheet will not block interaction with the rest of the screen
+ *   when visible.
+ * @param content The content of rest of the screen.
+ * @sample androidx.compose.material.navigation.samples.BottomSheetNavDemo
+ * @see [ModalBottomSheetLayout]
+ */
+@Suppress("MissingJvmstatic")
+@Composable
+// Keep defaults in sync with androidx.compose.material.ModalBottomSheetLayout
+public fun ModalBottomSheetLayout(
+    bottomSheetNavigator: BottomSheetNavigator,
+    modifier: Modifier = Modifier,
+    sheetGesturesEnabled: Boolean = true,
+    sheetShape: Shape = MaterialTheme.shapes.large,
+    sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
+    sheetBackgroundColor: Color = MaterialTheme.colors.surface,
+    sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
+    scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
+    content: @Composable () -> Unit
+) {
+    ModalBottomSheetLayout(
+        sheetState = bottomSheetNavigator.sheetState,
+        sheetContent = bottomSheetNavigator.sheetContent,
+        modifier = modifier,
+        sheetGesturesEnabled = sheetGesturesEnabled,
+        sheetShape = sheetShape,
+        sheetElevation = sheetElevation,
+        sheetBackgroundColor = sheetBackgroundColor,
+        sheetContentColor = sheetContentColor,
+        scrimColor = scrimColor,
+        content = content
+    )
+}
diff --git a/compose/material/material-ripple/benchmark/build.gradle b/compose/material/material-ripple/benchmark/build.gradle
index 1da1934..4d59f30 100644
--- a/compose/material/material-ripple/benchmark/build.gradle
+++ b/compose/material/material-ripple/benchmark/build.gradle
@@ -32,11 +32,11 @@
 }
 
 dependencies {
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:material:material-ripple")
-    androidTestImplementation project(":compose:runtime:runtime")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:material:material-ripple"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/compose/material/material/benchmark/build.gradle b/compose/material/material/benchmark/build.gradle
index 8a8f1b9..64803b4 100644
--- a/compose/material/material/benchmark/build.gradle
+++ b/compose/material/material/benchmark/build.gradle
@@ -33,13 +33,13 @@
 
 dependencies {
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:ui:ui-text:ui-text-benchmark")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:material:material")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-text:ui-text-benchmark"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:material:material"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/compose/material/material/build.gradle b/compose/material/material/build.gradle
index 3b67763b..ad67f6d 100644
--- a/compose/material/material/build.gradle
+++ b/compose/material/material/build.gradle
@@ -127,8 +127,8 @@
 }
 
 dependencies {
-    lintChecks project(":compose:material:material-lint")
-    lintPublish project(":compose:material:material-lint")
+    lintChecks(project(":compose:material:material-lint"))
+    lintPublish(project(":compose:material:material-lint"))
 }
 
 androidx {
@@ -141,12 +141,10 @@
     metalavaK2UastEnabled = false
     samples(project(":compose:material:material:material-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/material/material"
     namespace = "androidx.compose.material"
 }
diff --git a/compose/material/material/integration-tests/material-catalog/build.gradle b/compose/material/material/integration-tests/material-catalog/build.gradle
index c9aa047..29c6f75 100644
--- a/compose/material/material/integration-tests/material-catalog/build.gradle
+++ b/compose/material/material/integration-tests/material-catalog/build.gradle
@@ -32,13 +32,13 @@
 
 dependencies {
     implementation(libs.kotlinStdlib)
-    implementation project(":core:core")
-    implementation project(":compose:runtime:runtime")
-    implementation project(":compose:foundation:foundation-layout")
-    implementation project(":compose:ui:ui")
-    implementation project(":compose:material:material")
-    implementation project(":compose:material:material:material-samples")
-    implementation project(":navigation:navigation-compose")
+    implementation(project(":core:core"))
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:foundation:foundation-layout"))
+    implementation(project(":compose:ui:ui"))
+    implementation(project(":compose:material:material"))
+    implementation(project(":compose:material:material:material-samples"))
+    implementation(project(":navigation:navigation-compose"))
 }
 
 androidx {
diff --git a/compose/material/material/integration-tests/material-demos/build.gradle b/compose/material/material/integration-tests/material-demos/build.gradle
index 5417ae4..5d92707 100644
--- a/compose/material/material/integration-tests/material-demos/build.gradle
+++ b/compose/material/material/integration-tests/material-demos/build.gradle
@@ -22,7 +22,7 @@
     implementation(project(":compose:integration-tests:demos:common"))
     implementation(project(":compose:material:material"))
     implementation(project(":compose:material:material:material-samples"))
-    implementation project(":compose:material:material-navigation-samples")
+    implementation(project(":compose:material:material-navigation-samples"))
     implementation(project(":compose:runtime:runtime"))
     implementation(project(":compose:ui:ui"))
     implementation(project(":compose:ui:ui-text"))
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt
index 2a16cb3..e0ff4c1 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Scaffold.kt
@@ -109,9 +109,9 @@
  * <a href="https://material.io/design/layout/understanding-layout.html" class="external"
  * target="_blank">Material Design layout</a>.
  *
- * Scaffold implements the basic material design visual layout structure.
+ * Scaffold implements the basic Material Design visual layout structure.
  *
- * This component provides API to put together several material components to construct your screen,
+ * This component provides API to put together several Material components to construct your screen,
  * by ensuring proper layout strategy for them and collecting necessary data so these components
  * will work together correctly.
  *
@@ -127,12 +127,12 @@
  * @sample androidx.compose.material.samples.SimpleScaffoldWithTopBar
  *
  * More fancy usage with [BottomAppBar] with cutout and docked [FloatingActionButton], which
- * animates it's shape when clicked:
+ * animates its shape when clicked:
  *
  * @sample androidx.compose.material.samples.ScaffoldWithBottomBarAndCutout
  *
  * To show a [Snackbar], use [SnackbarHostState.showSnackbar]. Scaffold state already have
- * [ScaffoldState.snackbarHostState] when created
+ * [ScaffoldState.snackbarHostState] when created.
  *
  * @sample androidx.compose.material.samples.ScaffoldWithSimpleSnackbar
  * @param contentWindowInsets window insets to be passed to [content] slot via [PaddingValues]
@@ -170,9 +170,9 @@
  *   content color for [backgroundColor], or, if it is not a color from the theme, this will keep
  *   the same value set above this Surface.
  * @param content content of your screen. The lambda receives an [PaddingValues] that should be
- *   applied to the content root via Modifier.padding to properly offset top and bottom bars. If
- *   you're using VerticalScroller, apply this modifier to the child of the scroller, and not on the
- *   scroller itself.
+ *   applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ *   properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ *   the child of the scroll, and not on the scroll itself.
  */
 @Composable
 fun Scaffold(
@@ -244,9 +244,9 @@
  * <a href="https://material.io/design/layout/understanding-layout.html" class="external"
  * target="_blank">Material Design layout</a>.
  *
- * Scaffold implements the basic material design visual layout structure.
+ * Scaffold implements the basic Material Design visual layout structure.
  *
- * This component provides API to put together several material components to construct your screen,
+ * This component provides API to put together several Material components to construct your screen,
  * by ensuring proper layout strategy for them and collecting necessary data so these components
  * will work together correctly.
  *
@@ -259,7 +259,7 @@
  * @sample androidx.compose.material.samples.SimpleScaffoldWithTopBar
  *
  * More fancy usage with [BottomAppBar] with cutout and docked [FloatingActionButton], which
- * animates it's shape when clicked:
+ * animates its shape when clicked:
  *
  * @sample androidx.compose.material.samples.ScaffoldWithBottomBarAndCutout
  *
@@ -297,9 +297,9 @@
  *   content color for [backgroundColor], or, if it is not a color from the theme, this will keep
  *   the same value set above this Surface.
  * @param content content of your screen. The lambda receives an [PaddingValues] that should be
- *   applied to the content root via Modifier.padding to properly offset top and bottom bars. If
- *   you're using VerticalScroller, apply this modifier to the child of the scroller, and not on the
- *   scroller itself.
+ *   applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ *   properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ *   the child of the scroll, and not on the scroll itself.
  */
 @Composable
 fun Scaffold(
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
index aa58990..45efedc 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Slider.kt
@@ -138,9 +138,10 @@
  * @param enabled whether or not component is enabled and can be interacted with or not
  * @param valueRange range of values that Slider value can take. Passed [value] will be coerced to
  *   this range
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
  *   shouldn't be used to update the slider value (use [onValueChange] for that), but rather to know
  *   when the user has completed selecting a new value by ending a drag or a click.
@@ -382,9 +383,10 @@
  * @param enabled whether or not component is enabled and can we interacted with or not
  * @param valueRange range of values that Range Slider values can take. Passed [value] will be
  *   coerced to this range
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   range slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
  *   shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
  *   to know when the user has completed selecting a new value by ending a drag or a click.
diff --git a/compose/material3/adaptive/adaptive-layout/build.gradle b/compose/material3/adaptive/adaptive-layout/build.gradle
index 56d63c4..4c3c2d5 100644
--- a/compose/material3/adaptive/adaptive-layout/build.gradle
+++ b/compose/material3/adaptive/adaptive-layout/build.gradle
@@ -23,7 +23,6 @@
  */
 import androidx.build.LibraryType
 import androidx.build.PlatformIdentifier
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 plugins {
     id("AndroidXPlugin")
@@ -110,12 +109,10 @@
     legacyDisableKotlinStrictApiMode = true
     metalavaK2UastEnabled = false
     samples(project(":compose:material3:adaptive:adaptive-samples"))
+    addGoldenImageAssets()
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/material3/adaptive"
     namespace = "androidx.compose.material3.adaptive.layout"
 }
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/GoldenCommon.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/GoldenCommon.kt
index f16ff5e..5bf1297 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/GoldenCommon.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/GoldenCommon.kt
@@ -16,4 +16,4 @@
 
 package androidx.compose.material3.adaptive.layout
 
-internal const val GOLDEN_MATERIAL3_ADAPTIVE = "compose/material3/adaptive"
+internal const val GOLDEN_MATERIAL3_ADAPTIVE = "compose/material3/adaptive/adaptive-layout"
diff --git a/compose/material3/adaptive/adaptive-navigation/build.gradle b/compose/material3/adaptive/adaptive-navigation/build.gradle
index 759ce50..eb1274b 100644
--- a/compose/material3/adaptive/adaptive-navigation/build.gradle
+++ b/compose/material3/adaptive/adaptive-navigation/build.gradle
@@ -23,7 +23,6 @@
  */
 import androidx.build.LibraryType
 import androidx.build.PlatformIdentifier
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 plugins {
     id("AndroidXPlugin")
@@ -96,10 +95,6 @@
     }
 }
 
-android {
-    namespace = "androidx.compose.material3.adaptive.navigation"
-}
-
 androidx {
     name = "Material Adaptive"
     type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
@@ -109,10 +104,7 @@
     metalavaK2UastEnabled = false
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/material3/adaptive"
     namespace = "androidx.compose.material3.adaptive.navigation"
 }
diff --git a/compose/material3/adaptive/adaptive/build.gradle b/compose/material3/adaptive/adaptive/build.gradle
index 6dc0ad0..1636d92 100644
--- a/compose/material3/adaptive/adaptive/build.gradle
+++ b/compose/material3/adaptive/adaptive/build.gradle
@@ -23,7 +23,6 @@
  */
 import androidx.build.LibraryType
 import androidx.build.PlatformIdentifier
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 plugins {
     id("AndroidXPlugin")
@@ -96,10 +95,6 @@
     }
 }
 
-android {
-    namespace = "androidx.compose.material3.adaptive"
-}
-
 androidx {
     name = "Material Adaptive"
     type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
@@ -109,10 +104,7 @@
     metalavaK2UastEnabled = false
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/material3/adaptive"
     namespace = "androidx.compose.material3.adaptive"
 }
diff --git a/compose/material3/material3/build.gradle b/compose/material3/material3/build.gradle
index c930af7..153d2b4 100644
--- a/compose/material3/material3/build.gradle
+++ b/compose/material3/material3/build.gradle
@@ -124,8 +124,8 @@
 }
 
 dependencies {
-    lintChecks project(":compose:material3:material3-lint")
-    lintPublish project(":compose:material3:material3-lint")
+    lintChecks(project(":compose:material3:material3-lint"))
+    lintPublish(project(":compose:material3:material3-lint"))
 }
 
 androidx {
@@ -137,13 +137,11 @@
     metalavaK2UastEnabled = false
     samples(project(":compose:material3:material3:material3-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
 
-// Screenshot tests related setup
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/material3/material3"
     namespace = "androidx.compose.material3"
     // TODO(b/345531033)
     experimentalProperties["android.lint.useK2Uast"] = false
diff --git a/compose/material3/material3/integration-tests/material3-catalog/build.gradle b/compose/material3/material3/integration-tests/material3-catalog/build.gradle
index fc3f343..2dfcfc5 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/build.gradle
+++ b/compose/material3/material3/integration-tests/material3-catalog/build.gradle
@@ -34,7 +34,7 @@
 dependencies {
     implementation(libs.kotlinStdlib)
     implementation("androidx.core:core:1.12.0")
-    implementation project(":compose:runtime:runtime")
+    implementation(project(":compose:runtime:runtime"))
     implementation("androidx.compose.foundation:foundation-layout:1.6.0")
     implementation("androidx.compose.ui:ui:1.6.0")
     implementation("androidx.compose.material:material:1.6.0")
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DatePickerSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DatePickerSamples.kt
index 51bd2ed..439e915 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DatePickerSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DatePickerSamples.kt
@@ -142,7 +142,6 @@
     }
 }
 
-@Suppress("ClassVerificationFailure")
 @OptIn(ExperimentalMaterial3Api::class)
 @Preview
 @Sampled
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SplitButtonSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SplitButtonSamples.kt
index 0a7af3f..115b07c 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SplitButtonSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SplitButtonSamples.kt
@@ -68,7 +68,7 @@
                 onCheckedChange = { checked = it },
                 modifier =
                     Modifier.semantics {
-                        stateDescription = if (checked) "Checked" else "Unchecked"
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
                         contentDescription = "Toggle Button"
                     },
             ) {
@@ -148,11 +148,11 @@
             SplitButtonDefaults.TonalTrailingButton(
                 checked = checked,
                 onCheckedChange = { checked = it },
-                //                modifier =
-                //                    Modifier.semantics {
-                //                        stateDescription = if (checked) "Checked" else "Unchecked"
-                //                        contentDescription = "Toggle Button"
-                //                    },
+                modifier =
+                    Modifier.semantics {
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
+                        contentDescription = "Toggle Button"
+                    },
             ) {
                 val rotation: Float by
                     animateFloatAsState(
@@ -199,7 +199,7 @@
                 onCheckedChange = { checked = it },
                 modifier =
                     Modifier.semantics {
-                        stateDescription = if (checked) "Checked" else "Unchecked"
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
                         contentDescription = "Toggle Button"
                     },
             ) {
@@ -248,7 +248,7 @@
                 onCheckedChange = { checked = it },
                 modifier =
                     Modifier.semantics {
-                        stateDescription = if (checked) "Checked" else "Unchecked"
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
                         contentDescription = "Toggle Button"
                     },
             ) {
@@ -288,7 +288,12 @@
         trailingButton = {
             SplitButtonDefaults.TrailingButton(
                 checked = checked,
-                onCheckedChange = { checked = it }
+                onCheckedChange = { checked = it },
+                modifier =
+                    Modifier.semantics {
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
+                        contentDescription = "Toggle Button"
+                    },
             ) {
                 val rotation: Float by
                     animateFloatAsState(
@@ -330,7 +335,12 @@
         trailingButton = {
             SplitButtonDefaults.TrailingButton(
                 checked = checked,
-                onCheckedChange = { checked = it }
+                onCheckedChange = { checked = it },
+                modifier =
+                    Modifier.semantics {
+                        stateDescription = if (checked) "Expanded" else "Collapsed"
+                        contentDescription = "Toggle Button"
+                    },
             ) {
                 val rotation: Float by
                     animateFloatAsState(
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ChipTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ChipTest.kt
index f583f3d..df233ac 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ChipTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ChipTest.kt
@@ -19,9 +19,12 @@
 import android.os.Build
 import androidx.compose.foundation.horizontalScroll
 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.fillMaxSize
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.requiredHeight
 import androidx.compose.foundation.layout.requiredSize
 import androidx.compose.foundation.layout.requiredWidth
@@ -71,6 +74,7 @@
 import androidx.compose.ui.test.assertWidthIsEqualTo
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.click
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
 import androidx.compose.ui.test.hasClickAction
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
@@ -80,6 +84,7 @@
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import androidx.compose.ui.unit.width
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.MediumTest
@@ -514,6 +519,60 @@
     }
 
     @Test
+    fun intrinsicSize_filterChip() {
+        val iconSize = 24.dp
+        val horizontalPadding = 8.dp
+        val minTouchTarget = 48.dp
+
+        rule.setMaterialContent(lightColorScheme()) {
+            Column {
+                Box(Modifier.height(IntrinsicSize.Max).testTag("chipMax")) {
+                    FilterChip(
+                        selected = false,
+                        onClick = {},
+                        label = { Text("Text", modifier = Modifier.testTag("labelMax")) },
+                        leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) },
+                        trailingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) },
+                    )
+                }
+                Box(Modifier.height(IntrinsicSize.Min).testTag("chipMin")) {
+                    FilterChip(
+                        selected = false,
+                        onClick = {},
+                        label = { Text("Text", modifier = Modifier.testTag("labelMin")) },
+                        leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) },
+                        trailingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) },
+                    )
+                }
+            }
+        }
+
+        val labelMaxWidth =
+            rule.onNodeWithTag("labelMax", useUnmergedTree = true).getUnclippedBoundsInRoot().width
+        rule
+            .onNodeWithTag("chipMax")
+            .assertHeightIsEqualTo(minTouchTarget)
+            .assertWidthIsEqualTo(
+                iconSize +
+                    labelMaxWidth +
+                    iconSize +
+                    horizontalPadding * 4 // chip start, chip end, label start, label end
+            )
+
+        val labelMinWidth =
+            rule.onNodeWithTag("labelMin", useUnmergedTree = true).getUnclippedBoundsInRoot().width
+        rule
+            .onNodeWithTag("chipMin")
+            .assertHeightIsEqualTo(minTouchTarget)
+            .assertWidthIsEqualTo(
+                iconSize +
+                    labelMinWidth +
+                    iconSize +
+                    horizontalPadding * 4 // chip start, chip end, label start, label end
+            )
+    }
+
+    @Test
     fun longLabelDoesNotHideTrailingIcon_filterChip() {
         rule.setMaterialContent(lightColorScheme()) {
             FilterChip(
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/FloatingToolbarTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/FloatingToolbarTest.kt
index 1607eb4..c2bc520 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/FloatingToolbarTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/FloatingToolbarTest.kt
@@ -635,7 +635,7 @@
 
     @Test
     fun horizontalFloatingToolbar_withFab_customContentPadding() {
-        val padding = 64.dp
+        val padding = 48.dp
         rule.setMaterialContent(lightColorScheme()) {
             HorizontalFloatingToolbar(
                 modifier = Modifier.testTag(FloatingToolbarTestTag),
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
index 6df4b47..65cc199 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
@@ -683,21 +683,18 @@
             ModalBottomSheet(
                 onDismissRequest = {},
                 modifier = Modifier.testTag(topTag),
+                dragHandle = null,
                 sheetState = sheetState,
             ) {
                 if (showShortContent) {
-                    Box(Modifier.fillMaxWidth().height(100.dp))
+                    Box(Modifier.fillMaxWidth().height(1.dp))
                 } else {
                     Box(Modifier.fillMaxSize().testTag(sheetTag))
                 }
             }
         }
 
-        rule.onNodeWithTag(topTag).performTouchInput {
-            swipeDown()
-            swipeDown()
-        }
-
+        scope.launch { sheetState.hide() }
         rule.runOnIdle { assertThat(sheetState.currentValue).isEqualTo(SheetValue.Hidden) }
 
         showShortContent = true
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
index 1029be4..3393ea3 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
@@ -135,7 +135,7 @@
 
         rule
             .onNode(isToggleable())
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))
+            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
             .assertIsEnabled()
             .assertIsOff()
             .performClick()
@@ -171,7 +171,7 @@
             assertIsEnabled()
         }
         rule.onNodeWithTag("trailing button").apply {
-            assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))
+            assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
             assertIsEnabled()
         }
     }
@@ -207,7 +207,7 @@
             assertIsNotEnabled()
         }
         rule.onNodeWithTag("trailing button").apply {
-            assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))
+            assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
             assertIsNotEnabled()
         }
     }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
index 07bed9e..f86190b 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AppBar.kt
@@ -42,6 +42,7 @@
 import androidx.compose.foundation.layout.only
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.internal.FloatProducer
 import androidx.compose.material3.internal.ProvideContentColorTextStyle
 import androidx.compose.material3.internal.systemBarsForVisualComponents
 import androidx.compose.material3.tokens.BottomAppBarTokens
@@ -2597,7 +2598,7 @@
  * the actions are optional.
  *
  * @param modifier a [Modifier]
- * @param scrolledOffset a [ScrolledOffset] that provides the app bar offset in pixels (note that
+ * @param scrolledOffset a [FloatProducer] that provides the app bar offset in pixels (note that
  *   when the app bar is scrolled, the lambda will output negative values)
  * @param navigationIconContentColor the content color that will be applied via a
  *   [LocalContentColor] when composing the navigation icon
@@ -2623,7 +2624,7 @@
 @Composable
 private fun TopAppBarLayout(
     modifier: Modifier,
-    scrolledOffset: ScrolledOffset,
+    scrolledOffset: FloatProducer,
     navigationIconContentColor: Color,
     titleContentColor: Color,
     actionIconContentColor: Color,
@@ -2720,7 +2721,7 @@
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 private class TopAppBarMeasurePolicy(
-    val scrolledOffset: ScrolledOffset,
+    val scrolledOffset: FloatProducer,
     val titleVerticalArrangement: Arrangement.Vertical,
     val titleHorizontalAlignment: TopAppBarTitleAlignment,
     val titleBottomPadding: Int,
@@ -2761,7 +2762,7 @@
 
         // Subtract the scrolledOffset from the maxHeight. The scrolledOffset is expected to be
         // equal or smaller than zero.
-        val scrolledOffsetValue = scrolledOffset.offset()
+        val scrolledOffsetValue = scrolledOffset()
         val heightOffset = if (scrolledOffsetValue.isNaN()) 0 else scrolledOffsetValue.roundToInt()
 
         val maxLayoutHeight = max(expandedHeight.roundToPx(), titlePlaceable.height)
@@ -2897,11 +2898,6 @@
         }
 }
 
-/** A functional interface for providing an app-bar scroll offset. */
-private fun interface ScrolledOffset {
-    fun offset(): Float
-}
-
 /**
  * Returns a [TopAppBarScrollBehavior] that only adjusts its content offset, without adjusting any
  * properties that affect the height of a top app bar.
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
index fddd3f7..0c52f23 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Chip.kt
@@ -58,18 +58,27 @@
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.layout.IntrinsicMeasurable
+import androidx.compose.ui.layout.IntrinsicMeasureScope
 import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasurePolicy
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.layout.layoutId
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.offset
 import androidx.compose.ui.util.fastFirst
 import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastMaxOfOrNull
+import androidx.compose.ui.util.fastSumBy
 
 /**
  * <a href="https://m3.material.io/components/chips/overview" class="external"
@@ -2081,45 +2090,71 @@
                         }
                     )
                 }
-            }
-        ) { measurables, constraints ->
-            val leadingIconPlaceable: Placeable? =
-                measurables
-                    .fastFirstOrNull { it.layoutId == LeadingIconLayoutId }
-                    ?.measure(constraints.copy(minWidth = 0, minHeight = 0))
-            val leadingIconWidth = leadingIconPlaceable.widthOrZero
-            val leadingIconHeight = leadingIconPlaceable.heightOrZero
+            },
+            measurePolicy = remember { ChipLayoutMeasurePolicy() },
+        )
+    }
+}
 
-            val trailingIconPlaceable: Placeable? =
-                measurables
-                    .fastFirstOrNull { it.layoutId == TrailingIconLayoutId }
-                    ?.measure(constraints.copy(minWidth = 0, minHeight = 0))
-            val trailingIconWidth = trailingIconPlaceable.widthOrZero
-            val trailingIconHeight = trailingIconPlaceable.heightOrZero
+private class ChipLayoutMeasurePolicy : MeasurePolicy {
+    override fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: Constraints
+    ): MeasureResult {
+        val leadingIconPlaceable: Placeable? =
+            measurables
+                .fastFirstOrNull { it.layoutId == LeadingIconLayoutId }
+                ?.measure(constraints.copy(minWidth = 0, minHeight = 0))
+        val leadingIconWidth = leadingIconPlaceable.widthOrZero
+        val leadingIconHeight = leadingIconPlaceable.heightOrZero
 
-            val labelPlaceable =
-                measurables
-                    .fastFirst { it.layoutId == LabelLayoutId }
-                    .measure(
-                        constraints.offset(horizontal = -(leadingIconWidth + trailingIconWidth))
-                    )
+        val trailingIconPlaceable: Placeable? =
+            measurables
+                .fastFirstOrNull { it.layoutId == TrailingIconLayoutId }
+                ?.measure(constraints.copy(minWidth = 0, minHeight = 0))
+        val trailingIconWidth = trailingIconPlaceable.widthOrZero
+        val trailingIconHeight = trailingIconPlaceable.heightOrZero
 
-            val width = leadingIconWidth + labelPlaceable.width + trailingIconWidth
-            val height = maxOf(leadingIconHeight, labelPlaceable.height, trailingIconHeight)
+        val labelPlaceable =
+            measurables
+                .fastFirst { it.layoutId == LabelLayoutId }
+                .measure(constraints.offset(horizontal = -(leadingIconWidth + trailingIconWidth)))
 
-            layout(width, height) {
-                leadingIconPlaceable?.placeRelative(
-                    0,
-                    Alignment.CenterVertically.align(leadingIconHeight, height)
-                )
-                labelPlaceable.placeRelative(leadingIconWidth, 0)
-                trailingIconPlaceable?.placeRelative(
-                    leadingIconWidth + labelPlaceable.width,
-                    Alignment.CenterVertically.align(trailingIconHeight, height)
-                )
-            }
+        val width = leadingIconWidth + labelPlaceable.width + trailingIconWidth
+        val height = maxOf(leadingIconHeight, labelPlaceable.height, trailingIconHeight)
+
+        return layout(width, height) {
+            leadingIconPlaceable?.placeRelative(
+                0,
+                Alignment.CenterVertically.align(leadingIconHeight, height)
+            )
+            labelPlaceable.placeRelative(leadingIconWidth, 0)
+            trailingIconPlaceable?.placeRelative(
+                leadingIconWidth + labelPlaceable.width,
+                Alignment.CenterVertically.align(trailingIconHeight, height)
+            )
         }
     }
+
+    override fun IntrinsicMeasureScope.minIntrinsicHeight(
+        measurables: List<IntrinsicMeasurable>,
+        width: Int
+    ): Int = measurables.fastMaxOfOrNull { it.minIntrinsicHeight(width) } ?: 0
+
+    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
+        measurables: List<IntrinsicMeasurable>,
+        width: Int
+    ): Int = measurables.fastMaxOfOrNull { it.maxIntrinsicHeight(width) } ?: 0
+
+    override fun IntrinsicMeasureScope.minIntrinsicWidth(
+        measurables: List<IntrinsicMeasurable>,
+        height: Int
+    ): Int = measurables.fastSumBy { it.minIntrinsicWidth(height) }
+
+    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
+        measurables: List<IntrinsicMeasurable>,
+        height: Int
+    ): Int = measurables.fastSumBy { it.maxIntrinsicWidth(height) }
 }
 
 /**
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
index 4ebaa83..09ba3d4 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
@@ -46,6 +46,7 @@
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material3.internal.AnchoredDraggableState
 import androidx.compose.material3.internal.DraggableAnchors
+import androidx.compose.material3.internal.FloatProducer
 import androidx.compose.material3.internal.Strings
 import androidx.compose.material3.internal.anchoredDraggable
 import androidx.compose.material3.internal.getString
@@ -797,7 +798,7 @@
     drawerContainerColor: Color = DrawerDefaults.standardContainerColor,
     drawerContentColor: Color = contentColorFor(drawerContainerColor),
     drawerTonalElevation: Dp = DrawerDefaults.PermanentDrawerElevation,
-    drawerOffset: () -> Float = { 0F },
+    drawerOffset: FloatProducer = FloatProducer { 0F },
     content: @Composable ColumnScope.() -> Unit
 ) {
     val density = LocalDensity.current
@@ -862,7 +863,7 @@
  * @see horizontalScaleDown
  */
 private fun Modifier.horizontalScaleUp(
-    drawerOffset: () -> Float,
+    drawerOffset: FloatProducer,
     drawerWidth: Float,
     isRtl: Boolean
 ) = graphicsLayer {
@@ -880,7 +881,7 @@
  * @see horizontalScaleUp
  */
 private fun Modifier.horizontalScaleDown(
-    drawerOffset: () -> Float,
+    drawerOffset: FloatProducer,
     drawerWidth: Float,
     isRtl: Boolean
 ) = graphicsLayer {
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt
index ca6c6f2..bcbd38b 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt
@@ -48,13 +48,13 @@
  * <a href="https://m3.material.io/foundations/layout/understanding-layout/" class="external"
  * target="_blank">Material Design layout</a>.
  *
- * Scaffold implements the basic material design visual layout structure.
+ * Scaffold implements the basic Material Design visual layout structure.
  *
- * This component provides API to put together several material components to construct your screen,
+ * This component provides API to put together several Material components to construct your screen,
  * by ensuring proper layout strategy for them and collecting necessary data so these components
  * will work together correctly.
  *
- * Simple example of a Scaffold with [SmallTopAppBar], [FloatingActionButton]:
+ * Simple example of a Scaffold with [TopAppBar] and [FloatingActionButton]:
  *
  * @sample androidx.compose.material3.samples.SimpleScaffoldWithTopBar
  *
@@ -62,7 +62,7 @@
  *
  * @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
  * @param modifier the [Modifier] to be applied to this scaffold
- * @param topBar top app bar of the screen, typically a [SmallTopAppBar]
+ * @param topBar top app bar of the screen, typically a [TopAppBar]
  * @param bottomBar bottom bar of the screen, typically a [NavigationBar]
  * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
  *   [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
index 2ef7f6a..d7c7dce 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SearchBar.kt
@@ -16,14 +16,17 @@
 
 package androidx.compose.material3
 
+import androidx.annotation.FloatRange
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.EnterTransition
 import androidx.compose.animation.ExitTransition
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.AnimationVector1D
 import androidx.compose.animation.core.CubicBezierEasing
 import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.VectorConverter
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.expandVertically
 import androidx.compose.animation.fadeIn
@@ -38,16 +41,21 @@
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.exclude
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
 import androidx.compose.foundation.layout.sizeIn
-import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.GenericShape
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.KeyboardActions
@@ -60,11 +68,15 @@
 import androidx.compose.foundation.text.selection.TextSelectionColors
 import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
 import androidx.compose.material3.internal.BackEventCompat
+import androidx.compose.material3.internal.BackEventProgress
 import androidx.compose.material3.internal.BackHandler
+import androidx.compose.material3.internal.BasicEdgeToEdgeDialog
 import androidx.compose.material3.internal.MutableWindowInsets
 import androidx.compose.material3.internal.PredictiveBack
 import androidx.compose.material3.internal.PredictiveBackHandler
+import androidx.compose.material3.internal.PredictiveBackState
 import androidx.compose.material3.internal.Strings
+import androidx.compose.material3.internal.SwipeEdge
 import androidx.compose.material3.internal.getString
 import androidx.compose.material3.internal.textFieldBackground
 import androidx.compose.material3.tokens.ElevationTokens
@@ -78,12 +90,20 @@
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableFloatState
 import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
 import androidx.compose.runtime.structuralEqualityPolicy
+import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
@@ -92,14 +112,22 @@
 import androidx.compose.ui.geometry.RoundRect
 import androidx.compose.ui.geometry.toRect
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.addOutline
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.takeOrElse
 import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionOnScreen
+import androidx.compose.ui.platform.InterceptPlatformTextInput
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.platform.SoftwareKeyboardController
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.onClick
 import androidx.compose.ui.semantics.semantics
@@ -109,11 +137,13 @@
 import androidx.compose.ui.text.input.VisualTransformation
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntRect
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.round
 import androidx.compose.ui.util.fastFirst
 import androidx.compose.ui.util.fastFirstOrNull
 import androidx.compose.ui.util.lerp
@@ -124,7 +154,71 @@
 import kotlin.math.min
 import kotlin.math.roundToInt
 import kotlin.math.sign
+import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * <a href="https://m3.material.io/components/search/overview" class="external"
+ * target="_blank">Material Design search</a>.
+ *
+ * A search bar represents a field that allows users to enter a keyword or phrase and get relevant
+ * information. It can be used as a way to navigate through an app via search queries.
+ *
+ * ![Search bar
+ * image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
+ *
+ * A search bar expands when focused to display dynamic suggestions or search results. An expanded
+ * [SearchBar] displays its results in a full-screen dialog. If this expansion behavior is
+ * undesirable, for example on large tablet screens, consider using [DockedSearchBar] instead.
+ *
+ * @param state the state of the search bar. This state should also be passed to the [inputField].
+ * @param inputField the input field of this search bar that allows entering a query, typically a
+ *   [SearchBarDefaults.InputField].
+ * @param modifier the [Modifier] to be applied to this search bar when collapsed.
+ * @param shape the shape of this search bar when it is collapsed. When expanded, the shape will
+ *   always be [SearchBarDefaults.fullScreenShape].
+ * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
+ *   in different states. See [SearchBarDefaults.colors].
+ * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
+ *   translucent primary color overlay is applied on top of the container. A higher tonal elevation
+ *   value will result in a darker color in light theme and lighter color in dark theme. See also:
+ *   [Surface].
+ * @param shadowElevation the elevation for the shadow below this search bar.
+ * @param content the content of this search bar to display search results below the [inputField].
+ */
+@ExperimentalMaterial3Api
+@Composable
+internal fun SearchBar(
+    state: SearchBarState,
+    inputField: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    shape: Shape = SearchBarDefaults.inputFieldShape,
+    colors: SearchBarColors = SearchBarDefaults.colors(),
+    tonalElevation: Dp = SearchBarDefaults.TonalElevation,
+    shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    CollapsedSearchBar(
+        // Modifier gets passed to Collapsed because Expanded opens a separate dialog.
+        modifier = modifier,
+        state = state,
+        inputField = inputField,
+        shape = shape,
+        colors = colors,
+        tonalElevation = tonalElevation,
+        shadowElevation = shadowElevation
+    )
+    ExpandedFullScreenSearchBar(
+        state = state,
+        inputField = inputField,
+        collapsedShape = shape,
+        colors = colors,
+        tonalElevation = tonalElevation,
+        shadowElevation = shadowElevation,
+        content = content
+    )
+}
 
 /**
  * <a href="https://m3.material.io/components/search/overview" class="external"
@@ -328,6 +422,240 @@
     BackHandler(enabled = expanded) { onExpandedChange(false) }
 }
 
+/**
+ * A building block component representing a search bar in the collapsed state.
+ *
+ * Unless specific customization is needed, consider using a higher level component such as
+ * [SearchBar] or [DockedSearchBar] instead.
+ *
+ * @param state the state of the search bar. This state should also be passed to the [inputField].
+ * @param inputField the input field of this search bar that allows entering a query, typically a
+ *   [SearchBarDefaults.InputField].
+ * @param modifier the [Modifier] to be applied to this collapsed search bar.
+ * @param shape the shape of this search bar.
+ * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
+ *   in different states. See [SearchBarDefaults.colors].
+ * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
+ *   translucent primary color overlay is applied on top of the container. A higher tonal elevation
+ *   value will result in a darker color in light theme and lighter color in dark theme. See also:
+ *   [Surface].
+ * @param shadowElevation the elevation for the shadow below this search bar.
+ */
+@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition")
+@ExperimentalMaterial3Api
+@Composable
+internal fun CollapsedSearchBar(
+    state: SearchBarState,
+    inputField: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    shape: Shape = SearchBarDefaults.inputFieldShape,
+    colors: SearchBarColors = SearchBarDefaults.colors(),
+    tonalElevation: Dp = SearchBarDefaults.TonalElevation,
+    shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
+) {
+    // Disable when collapsed to avoid keyboard flicker when the expanded search bar opens.
+    DisableSoftKeyboard {
+        Surface(
+            modifier = modifier.onGloballyPositioned { state.collapsedCoords = it },
+            shape = shape,
+            color = colors.containerColor,
+            contentColor = contentColorFor(colors.containerColor),
+            tonalElevation = tonalElevation,
+            shadowElevation = shadowElevation,
+            content = inputField,
+        )
+    }
+}
+
+/**
+ * A building block component representing a search bar that is currently expanding or in the
+ * expanded state. This component is displayed in a new full-screen dialog.
+ *
+ * Unless specific customization is needed, consider using a higher level component such as
+ * [SearchBar] or [DockedSearchBar] instead.
+ *
+ * @param state the state of the search bar. This state should also be passed to the [inputField].
+ * @param inputField the input field of this search bar that allows entering a query, typically a
+ *   [SearchBarDefaults.InputField].
+ * @param modifier the [Modifier] to be applied to this expanded search bar.
+ * @param collapsedShape the shape of the search bar when it is collapsed. When fully expanded, the
+ *   shape will always be [SearchBarDefaults.fullScreenShape].
+ * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
+ *   in different states. See [SearchBarDefaults.colors].
+ * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
+ *   translucent primary color overlay is applied on top of the container. A higher tonal elevation
+ *   value will result in a darker color in light theme and lighter color in dark theme. See also:
+ *   [Surface].
+ * @param shadowElevation the elevation for the shadow below this search bar.
+ * @param windowInsets the window insets that this search bar will respect.
+ * @param content the content of this search bar to display search results below the [inputField].
+ */
+@ExperimentalMaterial3Api
+@Composable
+internal fun ExpandedFullScreenSearchBar(
+    state: SearchBarState,
+    inputField: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    collapsedShape: Shape = SearchBarDefaults.inputFieldShape,
+    colors: SearchBarColors = SearchBarDefaults.colors(),
+    tonalElevation: Dp = SearchBarDefaults.TonalElevation,
+    shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
+    windowInsets: @Composable () -> WindowInsets = { SearchBarDefaults.windowInsets },
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    if (!state.isExpanded) return
+
+    val coroutineScope = rememberCoroutineScope()
+
+    BasicEdgeToEdgeDialog(
+        onDismissRequest = { coroutineScope.launch { state.animateToCollapsed() } }
+    ) { predictiveBackState ->
+        val softwareKeyboardController = LocalSoftwareKeyboardController.current
+        SideEffect { state.softwareKeyboardController = softwareKeyboardController }
+        FullScreenSearchBarLayout(
+            state = state,
+            predictiveBackState = predictiveBackState,
+            inputField = inputField,
+            modifier = modifier,
+            collapsedShape = collapsedShape,
+            colors = colors,
+            tonalElevation = tonalElevation,
+            shadowElevation = shadowElevation,
+            windowInsets = windowInsets(),
+            content = content,
+        )
+    }
+}
+
+/**
+ * The state of a search bar.
+ *
+ * @property focusRequester The [FocusRequester] to request focus on the search bar.
+ *   [SearchBarDefaults.InputField] applies this automatically. Custom input fields must attach this
+ *   focus requester using [Modifier.focusRequester].
+ */
+@ExperimentalMaterial3Api
+@Stable
+internal class SearchBarState
+private constructor(
+    private val animatable: Animatable<Float, AnimationVector1D>,
+    private val animationSpecForExpand: AnimationSpec<Float>,
+    private val animationSpecForCollapse: AnimationSpec<Float>,
+    val focusRequester: FocusRequester,
+) {
+    /**
+     * Construct a [SearchBarState].
+     *
+     * @param initialExpanded the initial value of whether the search bar is expanded.
+     * @param animationSpecForExpand the animation spec used when the search bar expands.
+     * @param animationSpecForCollapse the animation spec used when the search bar collapses.
+     * @param focusRequester the focus requester to be applied to the search bar's input field.
+     */
+    constructor(
+        initialExpanded: Boolean,
+        animationSpecForExpand: AnimationSpec<Float>,
+        animationSpecForCollapse: AnimationSpec<Float>,
+        focusRequester: FocusRequester = FocusRequester(),
+    ) : this(
+        animatable = Animatable(if (initialExpanded) 1f else 0f),
+        animationSpecForExpand = animationSpecForExpand,
+        animationSpecForCollapse = animationSpecForCollapse,
+        focusRequester = focusRequester,
+    )
+
+    /** The layout coordinates, if available, of the search bar when it is collapsed. */
+    var collapsedCoords: LayoutCoordinates? by mutableStateOf(null)
+
+    /**
+     * The animation progress of the search bar, where 0 represents the collapsed state and 1
+     * represents the expanded state.
+     */
+    @get:FloatRange(from = 0.0, to = 1.0)
+    val progress: Float
+        get() = animatable.value.coerceIn(0f, 1f)
+
+    /**
+     * Whether this search bar is expanded (showing search results), or in the process of expanding.
+     */
+    val isExpanded: Boolean by derivedStateOf { progress > 0f }
+
+    internal var softwareKeyboardController: SoftwareKeyboardController? = null
+
+    /** Animate the search bar to its expanded state. */
+    suspend fun animateToExpanded() {
+        animatable.animateTo(targetValue = 1f, animationSpec = animationSpecForExpand)
+        focusRequester.requestFocus()
+    }
+
+    /** Animate the search bar to its collapsed state. */
+    suspend fun animateToCollapsed() {
+        softwareKeyboardController?.hide()
+        animatable.animateTo(targetValue = 0f, animationSpec = animationSpecForCollapse)
+    }
+
+    /** Snap the search bar progress to the given [fraction]. */
+    suspend fun snapTo(fraction: Float) {
+        animatable.snapTo(fraction)
+    }
+
+    companion object {
+        fun Saver(
+            animationSpecForExpand: AnimationSpec<Float>,
+            animationSpecForCollapse: AnimationSpec<Float>,
+            focusRequester: FocusRequester,
+        ): Saver<SearchBarState, *> =
+            listSaver(
+                save = { listOf(it.progress) },
+                restore = {
+                    SearchBarState(
+                        animatable = Animatable(it[0], Float.VectorConverter),
+                        animationSpecForExpand = animationSpecForExpand,
+                        animationSpecForCollapse = animationSpecForCollapse,
+                        focusRequester = focusRequester,
+                    )
+                },
+            )
+    }
+}
+
+/**
+ * Create and remember a [SearchBarState].
+ *
+ * @param initialExpanded the initial value of whether the search bar is expanded.
+ * @param animationSpecForExpand the animation spec used when the search bar expands.
+ * @param animationSpecForCollapse the animation spec used when the search bar collapses.
+ * @param focusRequester the focus requester to be applied to the search bar's input field.
+ */
+@ExperimentalMaterial3Api
+@Composable
+internal fun rememberSearchBarState(
+    initialExpanded: Boolean = false,
+    animationSpecForExpand: AnimationSpec<Float> = MotionSchemeKeyTokens.SlowSpatial.value(),
+    animationSpecForCollapse: AnimationSpec<Float> = MotionSchemeKeyTokens.DefaultSpatial.value(),
+    focusRequester: FocusRequester? = null,
+): SearchBarState {
+    @Suppress("NAME_SHADOWING") val focusRequester = focusRequester ?: remember { FocusRequester() }
+    return rememberSaveable(
+        initialExpanded,
+        animationSpecForExpand,
+        animationSpecForCollapse,
+        focusRequester,
+        saver =
+            SearchBarState.Saver(
+                animationSpecForExpand = animationSpecForExpand,
+                animationSpecForCollapse = animationSpecForCollapse,
+                focusRequester = focusRequester,
+            )
+    ) {
+        SearchBarState(
+            initialExpanded = initialExpanded,
+            animationSpecForExpand = animationSpecForExpand,
+            animationSpecForCollapse = animationSpecForCollapse,
+            focusRequester = focusRequester,
+        )
+    }
+}
+
 /** Defaults used in [SearchBar] and [DockedSearchBar]. */
 @ExperimentalMaterial3Api
 object SearchBarDefaults {
@@ -361,7 +689,7 @@
 
     /** Default window insets for a [SearchBar]. */
     val windowInsets: WindowInsets
-        @Composable get() = WindowInsets.statusBars
+        @Composable get() = WindowInsets.safeDrawing
 
     /**
      * Creates a [SearchBarColors] that represents the different colors used in parts of the search
@@ -497,8 +825,170 @@
      * A text field to input a query in a search bar.
      *
      * This overload of [InputField] uses [TextFieldState] to keep track of the text content and
-     * position of the cursor or selection. It is the recommended overload to use with [SearchBar]
-     * and [DockedSearchBar].
+     * position of the cursor or selection, and [SearchBarState] to keep track of the state of the
+     * search bar.
+     *
+     * @param textFieldState [TextFieldState] that holds the internal editing state of the input
+     *   field.
+     * @param searchBarState the state of the search bar as a whole.
+     * @param onSearch the callback to be invoked when the input service triggers the
+     *   [ImeAction.Search] action. The current query in the [textFieldState] comes as a parameter
+     *   of the callback.
+     * @param modifier the [Modifier] to be applied to this input field.
+     * @param enabled the enabled state of this input field. When `false`, this component will not
+     *   respond to user input, and it will appear visually disabled and disabled to accessibility
+     *   services.
+     * @param readOnly controls the editable state of the input field. When `true`, the field cannot
+     *   be modified. However, a user can focus it and copy text from it.
+     * @param textStyle the style to be applied to the input text. Defaults to [LocalTextStyle].
+     * @param placeholder the placeholder to be displayed when the input text is empty.
+     * @param leadingIcon the leading icon to be displayed at the start of the input field.
+     * @param trailingIcon the trailing icon to be displayed at the end of the input field.
+     * @param prefix the optional prefix to be displayed before the input text.
+     * @param suffix the optional suffix to be displayed after the input text.
+     * @param inputTransformation optional [InputTransformation] that will be used to transform
+     *   changes to the [TextFieldState] made by the user. The transformation will be applied to
+     *   changes made by hardware and software keyboard events, pasting or dropping text,
+     *   accessibility services, and tests. The transformation will _not_ be applied when changing
+     *   the [textFieldState] programmatically, or when the transformation is changed. If the
+     *   transformation is changed on an existing text field, it will be applied to the next user
+     *   edit. The transformation will not immediately affect the current [textFieldState].
+     * @param outputTransformation optional [OutputTransformation] that transforms how the contents
+     *   of the text field are presented.
+     * @param scrollState scroll state that manages the horizontal scroll of the input field.
+     * @param shape the shape of the input field.
+     * @param colors [TextFieldColors] that will be used to resolve the colors used for this input
+     *   field in different states. See [SearchBarDefaults.inputFieldColors].
+     * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+     *   emitting [Interaction]s for this input field. You can use this to change the search bar's
+     *   appearance or preview the search bar in different states. Note that if `null` is provided,
+     *   interactions will still happen internally.
+     */
+    @ExperimentalMaterial3Api
+    @Composable
+    internal fun InputField(
+        textFieldState: TextFieldState,
+        searchBarState: SearchBarState,
+        onSearch: (String) -> Unit,
+        modifier: Modifier = Modifier,
+        enabled: Boolean = true,
+        readOnly: Boolean = false,
+        textStyle: TextStyle = LocalTextStyle.current,
+        placeholder: @Composable (() -> Unit)? = null,
+        leadingIcon: @Composable (() -> Unit)? = null,
+        trailingIcon: @Composable (() -> Unit)? = null,
+        prefix: @Composable (() -> Unit)? = null,
+        suffix: @Composable (() -> Unit)? = null,
+        inputTransformation: InputTransformation? = null,
+        outputTransformation: OutputTransformation? = null,
+        scrollState: ScrollState = rememberScrollState(),
+        shape: Shape = inputFieldShape,
+        colors: TextFieldColors = inputFieldColors(),
+        interactionSource: MutableInteractionSource? = null,
+    ) {
+        @Suppress("NAME_SHADOWING")
+        val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+
+        val focused = interactionSource.collectIsFocusedAsState().value
+        val focusManager = LocalFocusManager.current
+
+        val searchSemantics = getString(Strings.SearchBarSearch)
+        val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable)
+
+        val textColor =
+            textStyle.color.takeOrElse {
+                colors.textColor(enabled, isError = false, focused = focused)
+            }
+        val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
+
+        val coroutineScope = rememberCoroutineScope()
+
+        BasicTextField(
+            state = textFieldState,
+            modifier =
+                modifier
+                    .sizeIn(
+                        minWidth = SearchBarMinWidth,
+                        maxWidth = SearchBarMaxWidth,
+                        minHeight = InputFieldHeight,
+                    )
+                    .focusRequester(searchBarState.focusRequester)
+                    .onFocusChanged {
+                        if (it.isFocused) {
+                            coroutineScope.launch { searchBarState.animateToExpanded() }
+                        }
+                    }
+                    .semantics {
+                        contentDescription = searchSemantics
+                        if (searchBarState.isExpanded) {
+                            stateDescription = suggestionsAvailableSemantics
+                        }
+                        onClick {
+                            searchBarState.focusRequester.requestFocus()
+                            true
+                        }
+                    },
+            enabled = enabled,
+            readOnly = readOnly,
+            lineLimits = TextFieldLineLimits.SingleLine,
+            textStyle = mergedTextStyle,
+            cursorBrush = SolidColor(colors.cursorColor(isError = false)),
+            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+            onKeyboardAction = { onSearch(textFieldState.text.toString()) },
+            interactionSource = interactionSource,
+            inputTransformation = inputTransformation,
+            outputTransformation = outputTransformation,
+            scrollState = scrollState,
+            decorator =
+                TextFieldDefaults.decorator(
+                    state = textFieldState,
+                    enabled = enabled,
+                    lineLimits = TextFieldLineLimits.SingleLine,
+                    outputTransformation = outputTransformation,
+                    interactionSource = interactionSource,
+                    placeholder = placeholder,
+                    leadingIcon =
+                        leadingIcon?.let { leading ->
+                            { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } }
+                        },
+                    trailingIcon =
+                        trailingIcon?.let { trailing ->
+                            { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } }
+                        },
+                    prefix = prefix,
+                    suffix = suffix,
+                    colors = colors,
+                    contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
+                    container = {
+                        val containerColor =
+                            animateColorAsState(
+                                targetValue =
+                                    colors.containerColor(
+                                        enabled = enabled,
+                                        isError = false,
+                                        focused = focused
+                                    ),
+                                animationSpec = MotionSchemeKeyTokens.FastEffects.value(),
+                            )
+                        Box(Modifier.textFieldBackground(containerColor::value, shape))
+                    },
+                )
+        )
+
+        val shouldClearFocus = !searchBarState.isExpanded && focused
+        LaunchedEffect(searchBarState.isExpanded) {
+            if (shouldClearFocus) {
+                focusManager.clearFocus()
+            }
+        }
+    }
+
+    /**
+     * A text field to input a query in a search bar.
+     *
+     * This overload of [InputField] uses [TextFieldState] to keep track of the text content and
+     * position of the cursor or selection, and [expanded] and [onExpandedChange] to keep track of
+     * the state of the search bar.
      *
      * @param state [TextFieldState] that holds the internal editing state of the input field.
      * @param onSearch the callback to be invoked when the input service triggers the
@@ -1355,6 +1845,228 @@
     }
 }
 
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FullScreenSearchBarLayout(
+    state: SearchBarState,
+    predictiveBackState: PredictiveBackState,
+    inputField: @Composable () -> Unit,
+    modifier: Modifier,
+    collapsedShape: Shape,
+    colors: SearchBarColors,
+    tonalElevation: Dp,
+    shadowElevation: Dp,
+    windowInsets: WindowInsets,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    val backEvent by remember { derivedStateOf { predictiveBackState.value } }
+    val firstInProgressValue =
+        remember { mutableStateOf<BackEventProgress.InProgress?>(null) }
+            .apply {
+                when (val event = backEvent) {
+                    is BackEventProgress.InProgress -> if (value == null) value = event
+                    BackEventProgress.NotRunning -> value = null
+                    BackEventProgress.Completed -> Unit
+                }
+            }
+    val lastInProgressValue =
+        remember { mutableStateOf<BackEventProgress.InProgress?>(null) }
+            .apply {
+                when (val event = backEvent) {
+                    is BackEventProgress.InProgress -> value = event
+                    BackEventProgress.NotRunning -> value = null
+                    BackEventProgress.Completed -> Unit
+                }
+            }
+
+    val density = LocalDensity.current
+    val fullScreenShape = SearchBarDefaults.fullScreenShape
+    val animatedShape =
+        remember(density, fullScreenShape) {
+            GenericShape { size, layoutDirection ->
+                if (collapsedShape === CircleShape && fullScreenShape === RectangleShape) {
+                    // The shape can only be animated if it's the default spec value
+                    val radius =
+                        with(density) {
+                            val fraction =
+                                max(1 - state.progress, lastInProgressValue.value.transform())
+                            (SearchBarCornerRadius * fraction).toPx()
+                        }
+                    if (radius < 1e-3) {
+                        addRect(size.toRect())
+                    } else {
+                        addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
+                    }
+                } else {
+                    val shape = if (state.progress < 0.5f) collapsedShape else fullScreenShape
+                    addOutline(shape.createOutline(size, layoutDirection, density))
+                }
+            }
+        }
+
+    // Top window insets need to be animated, but `Modifier.windowInsetsPadding` does not support
+    // animation. The top insets are separated out so the animation calculations can be done
+    // manually in the Layout's MeasureScope.
+    val unconsumedInsets = remember { MutableWindowInsets() }
+    val nonTopInsets =
+        unconsumedInsets.insets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)
+    Layout(
+        modifier =
+            modifier
+                .onConsumedWindowInsetsChanged { consumedInsets ->
+                    unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
+                }
+                .consumeWindowInsets(windowInsets),
+        content = {
+            Box(
+                modifier =
+                    Modifier.layoutId(LayoutIdInputField)
+                        .padding(nonTopInsets.only(WindowInsetsSides.Horizontal).asPaddingValues()),
+                propagateMinConstraints = true,
+            ) {
+                inputField()
+            }
+
+            Surface(
+                modifier = Modifier.layoutId(LayoutIdSurface),
+                shape = animatedShape,
+                color = colors.containerColor,
+                contentColor = contentColorFor(colors.containerColor),
+                tonalElevation = tonalElevation,
+                shadowElevation = shadowElevation,
+                content = {},
+            )
+
+            Column(
+                Modifier.layoutId(LayoutIdSearchContent).padding(nonTopInsets.asPaddingValues())
+            ) {
+                HorizontalDivider(color = colors.dividerColor)
+                content()
+            }
+        },
+    ) { measurables, constraints ->
+        val predictiveBackProgress = lastInProgressValue.value.transform()
+
+        val predictiveBackEndWidth =
+            (constraints.maxWidth * SearchBarPredictiveBackMinScale)
+                .roundToInt()
+                .coerceAtLeast(state.collapsedBounds.width)
+        val predictiveBackEndHeight =
+            (constraints.maxHeight * SearchBarPredictiveBackMinScale)
+                .roundToInt()
+                .coerceAtLeast(state.collapsedBounds.height)
+        val endWidth = lerp(constraints.maxWidth, predictiveBackEndWidth, predictiveBackProgress)
+        val endHeight = lerp(constraints.maxHeight, predictiveBackEndHeight, predictiveBackProgress)
+        val width =
+            constraints.constrainWidth(lerp(state.collapsedBounds.width, endWidth, state.progress))
+        val height =
+            constraints.constrainHeight(
+                lerp(state.collapsedBounds.height, endHeight, state.progress)
+            )
+
+        val surfaceMeasurable = measurables.fastFirst { it.layoutId == LayoutIdSurface }
+        val surfacePlaceable = surfaceMeasurable.measure(Constraints.fixed(width, height))
+
+        val inputFieldMeasurable = measurables.fastFirst { it.layoutId == LayoutIdInputField }
+        val inputFieldPlaceable =
+            inputFieldMeasurable.measure(Constraints.fixed(width, state.collapsedBounds.height))
+
+        val contentMeasurable = measurables.fastFirst { it.layoutId == LayoutIdSearchContent }
+        val contentPlaceable =
+            contentMeasurable.measure(
+                Constraints(
+                    minWidth = width,
+                    maxWidth = width,
+                    minHeight = 0,
+                    maxHeight = height,
+                )
+            )
+
+        layout(constraints.maxWidth, constraints.maxHeight) {
+            val topPadding =
+                unconsumedInsets.getTop(this@Layout) + SearchBarVerticalPadding.roundToPx()
+            val bottomPadding = SearchBarVerticalPadding.roundToPx()
+            val animatedTopPadding =
+                lerp(0, topPadding, min(state.progress, 1 - predictiveBackProgress))
+            val animatedBottomPadding = lerp(0, bottomPadding, state.progress)
+
+            fun BackEventProgress.InProgress.endOffsetX(): Int =
+                (if (swipeEdge == SwipeEdge.Left) {
+                        constraints.maxWidth -
+                            SearchBarPredictiveBackMinMargin.roundToPx() -
+                            predictiveBackEndWidth
+                    } else {
+                        SearchBarPredictiveBackMinMargin.roundToPx()
+                    })
+                    .coerceAtLeast(state.collapsedBounds.right - predictiveBackEndWidth)
+                    .coerceAtMost(state.collapsedBounds.left)
+
+            fun BackEventProgress.InProgress.endOffsetY(): Int {
+                val absoluteDeltaY = this.touchY - (firstInProgressValue.value?.touchY ?: return 0)
+                val relativeDeltaY = abs(absoluteDeltaY) / constraints.maxHeight
+
+                val availableVerticalSpace =
+                    ((constraints.maxHeight - predictiveBackEndHeight) / 2 -
+                            SearchBarPredictiveBackMinMargin.roundToPx())
+                        .coerceAtLeast(0)
+                val totalOffsetY =
+                    min(
+                        availableVerticalSpace,
+                        SearchBarPredictiveBackMaxOffsetY.roundToPx(),
+                    )
+                val interpolatedOffsetY = lerp(0, totalOffsetY, relativeDeltaY)
+                return (interpolatedOffsetY * sign(absoluteDeltaY).toInt() + topPadding)
+                    .coerceAtMost(state.collapsedBounds.top)
+            }
+
+            val endOffsetX =
+                lerp(
+                    0,
+                    lastInProgressValue.value?.endOffsetX() ?: 0,
+                    predictiveBackProgress,
+                )
+            val endOffsetY =
+                lerp(
+                    0,
+                    lastInProgressValue.value?.endOffsetY() ?: 0,
+                    predictiveBackProgress,
+                )
+            val offsetX = lerp(state.collapsedBounds.left, endOffsetX, state.progress)
+            val offsetY = lerp(state.collapsedBounds.top, endOffsetY, state.progress)
+
+            surfacePlaceable.place(x = offsetX, y = offsetY)
+            inputFieldPlaceable.place(x = offsetX, y = offsetY + animatedTopPadding)
+            contentPlaceable.placeWithLayer(
+                x = offsetX,
+                y =
+                    offsetY +
+                        animatedTopPadding +
+                        inputFieldPlaceable.height +
+                        animatedBottomPadding,
+                layerBlock = { alpha = state.progress },
+            )
+        }
+    }
+}
+
+private fun BackEventProgress.InProgress?.transform(): Float =
+    if (this == null) 0f else PredictiveBack.transform(this.progress)
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun DisableSoftKeyboard(content: @Composable () -> Unit) {
+    InterceptPlatformTextInput(
+        interceptor = { _, _ -> awaitCancellation() },
+        content = content,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+private val SearchBarState.collapsedBounds: IntRect
+    get() =
+        collapsedCoords?.let { IntRect(offset = it.positionOnScreen().round(), size = it.size) }
+            ?: IntRect.Zero
+
 private fun calculatePredictiveBackMultiplier(
     currentBackEvent: BackEventCompat?,
     progress: Float,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
index 1b29dd1..2e0704a 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
@@ -148,9 +148,10 @@
  *   services.
  * @param valueRange range of values that this slider can take. The passed [value] will be coerced
  *   to this range.
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished called when value change has ended. This should not be used to
  *   update the slider value (use [onValueChange] instead), but rather to know when the user has
  *   completed selecting a new value by ending a drag or a click.
@@ -240,9 +241,10 @@
  * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
  *   for this slider. You can create and pass in your own `remember`ed instance to observe
  *   [Interaction]s and customize the appearance / behavior of this slider in different states.
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param thumb the thumb to be displayed on the slider, it is placed on top of the track. The
  *   lambda receives a [SliderState] which is used to obtain the current active track.
  * @param track the track to be displayed on the slider, it is placed underneath the thumb. The
@@ -389,9 +391,10 @@
  * @param enabled whether or not component is enabled and can we interacted with or not
  * @param valueRange range of values that Range Slider values can take. Passed [value] will be
  *   coerced to this range
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   range slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
  *   shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
  *   to know when the user has completed selecting a new value by ending a drag or a click.
@@ -489,9 +492,10 @@
  * @param endInteractionSource the [MutableInteractionSource] representing the stream of
  *   [Interaction]s for the end thumb. You can create and pass in your own `remember`ed instance to
  *   observe.
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   range slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param startThumb the start thumb to be displayed on the Range Slider. The lambda receives a
  *   [RangeSliderState] which is used to obtain the current active track.
  * @param endThumb the end thumb to be displayed on the Range Slider. The lambda receives a
@@ -2020,9 +2024,10 @@
  *
  * @param value [Float] that indicates the initial position of the thumb. If outside of [valueRange]
  *   provided, value will be coerced to this range.
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
  *   shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
  *   to know when the user has completed selecting a new value by ending a drag or a click.
@@ -2141,9 +2146,10 @@
  *   slider. If outside of [valueRange] provided, value will be coerced to this range.
  * @param activeRangeEnd [Float] that indicates the initial end of the active range of the slider.
  *   If outside of [valueRange] provided, value will be coerced to this range.
- * @param steps if positive, specifies the amount of discrete allowable values (in addition to the
- *   endpoints of the value range). Step values are evenly distributed across the range. If 0, the
- *   range slider will behave continuously and allow any value from the range. Must not be negative.
+ * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
+ *   of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
+ *   distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
+ *   continuously and allow any value from the range. Must not be negative.
  * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
  *   shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
  *   to know when the user has completed selecting a new value by ending a drag or a click.
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SplitButton.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SplitButton.kt
index d4c416f..b1a95b3 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SplitButton.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SplitButton.kt
@@ -532,7 +532,7 @@
                             )
                         }
                     }
-                    .semantics { role = Role.Checkbox },
+                    .semantics { role = Role.Button },
             enabled = enabled,
             shape = shape,
             color = colors.containerColor,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
index 9006ec5..95ce48f 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextField.kt
@@ -16,9 +16,16 @@
 
 package androidx.compose.material3
 
-import androidx.compose.foundation.BorderStroke
+import androidx.compose.animation.VectorConverter
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.AnimationVector4D
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.snap
 import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.interaction.FocusInteraction
 import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.interaction.collectIsFocusedAsState
 import androidx.compose.foundation.layout.Box
@@ -41,6 +48,7 @@
 import androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine
 import androidx.compose.foundation.text.input.TextFieldState
 import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.material3.TextFieldDefaults.defaultTextFieldColors
 import androidx.compose.material3.internal.AboveLabelBottomPadding
 import androidx.compose.material3.internal.AboveLabelHorizontalPadding
 import androidx.compose.material3.internal.ContainerId
@@ -67,15 +75,17 @@
 import androidx.compose.material3.internal.subtractConstraintSafely
 import androidx.compose.material3.internal.textFieldHorizontalIconPadding
 import androidx.compose.material3.internal.widthOrZero
+import androidx.compose.material3.tokens.FilledTextFieldTokens
+import androidx.compose.material3.tokens.MotionSchemeKeyTokens
 import androidx.compose.material3.tokens.MotionTokens.EasingEmphasizedAccelerateCubicBezier
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.State
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.draw.CacheDrawModifierNode
 import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.SolidColor
@@ -90,6 +100,11 @@
 import androidx.compose.ui.layout.MeasureScope
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.currentValueOf
+import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
@@ -113,6 +128,8 @@
 import androidx.compose.ui.util.lerp
 import kotlin.math.max
 import kotlin.math.roundToInt
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
 
 /**
  * <a href="https://m3.material.io/components/text-fields/overview" class="external"
@@ -1400,44 +1417,242 @@
     }
 }
 
-/** A draw modifier that draws a bottom indicator line in [TextField] */
-internal fun Modifier.drawIndicatorLine(
-    indicatorBorder: State<BorderStroke>,
-    textFieldShape: Shape,
-): Modifier {
-    return drawWithCache {
-        val strokeWidth = indicatorBorder.value.width.toPx()
-        val textFieldShapePath =
-            Path().apply {
-                addOutline(
-                    textFieldShape.createOutline(
-                        size,
-                        layoutDirection,
-                        density = this@drawWithCache
-                    )
-                )
-            }
-        val linePath =
-            Path().apply {
-                addRect(
-                    Rect(
-                        left = 0f,
-                        top = size.height - strokeWidth,
-                        right = size.width,
-                        bottom = size.height,
-                    )
-                )
-            }
-        val clippedLine = linePath and textFieldShapePath
+internal data class IndicatorLineElement(
+    val enabled: Boolean,
+    val isError: Boolean,
+    val interactionSource: InteractionSource,
+    val colors: TextFieldColors?,
+    val textFieldShape: Shape?,
+    val focusedIndicatorLineThickness: Dp,
+    val unfocusedIndicatorLineThickness: Dp,
+) : ModifierNodeElement<IndicatorLineNode>() {
+    override fun create(): IndicatorLineNode {
+        return IndicatorLineNode(
+            enabled = enabled,
+            isError = isError,
+            interactionSource = interactionSource,
+            colors = colors,
+            textFieldShape = textFieldShape,
+            focusedIndicatorWidth = focusedIndicatorLineThickness,
+            unfocusedIndicatorWidth = unfocusedIndicatorLineThickness,
+        )
+    }
 
-        onDrawWithContent {
-            drawContent()
-            drawPath(
-                path = clippedLine,
-                brush = indicatorBorder.value.brush,
+    override fun update(node: IndicatorLineNode) {
+        node.update(
+            enabled = enabled,
+            isError = isError,
+            interactionSource = interactionSource,
+            colors = colors,
+            textFieldShape = textFieldShape,
+            focusedIndicatorWidth = focusedIndicatorLineThickness,
+            unfocusedIndicatorWidth = unfocusedIndicatorLineThickness,
+        )
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "indicatorLine"
+        properties["enabled"] = enabled
+        properties["isError"] = isError
+        properties["interactionSource"] = interactionSource
+        properties["colors"] = colors
+        properties["textFieldShape"] = textFieldShape
+        properties["focusedIndicatorLineThickness"] = focusedIndicatorLineThickness
+        properties["unfocusedIndicatorLineThickness"] = unfocusedIndicatorLineThickness
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal class IndicatorLineNode(
+    private var enabled: Boolean,
+    private var isError: Boolean,
+    private var interactionSource: InteractionSource,
+    colors: TextFieldColors?,
+    textFieldShape: Shape?,
+    private var focusedIndicatorWidth: Dp,
+    private var unfocusedIndicatorWidth: Dp,
+) : DelegatingNode(), CompositionLocalConsumerModifierNode {
+    private var focused = false
+    private var trackFocusStateJob: Job? = null
+
+    private var _colors: TextFieldColors? = colors
+    private val colors: TextFieldColors
+        get() =
+            _colors
+                ?: currentValueOf(LocalColorScheme)
+                    .defaultTextFieldColors(currentValueOf(LocalTextSelectionColors))
+
+    // Must be initialized in `onAttach` so `colors` can read from the `MaterialTheme`
+    private var colorAnimatable: Animatable<Color, AnimationVector4D>? = null
+
+    private var _shape: Shape? = textFieldShape
+        private set(value) {
+            if (field != value) {
+                field = value
+                drawWithCacheModifierNode.invalidateDrawCache()
+            }
+        }
+
+    private val shape: Shape
+        get() =
+            _shape ?: currentValueOf(LocalShapes).fromToken(FilledTextFieldTokens.ContainerShape)
+
+    private val widthAnimatable: Animatable<Dp, AnimationVector1D> =
+        Animatable(
+            initialValue =
+                if (focused && this.enabled) this.focusedIndicatorWidth
+                else this.unfocusedIndicatorWidth,
+            typeConverter = Dp.VectorConverter,
+        )
+
+    fun update(
+        enabled: Boolean,
+        isError: Boolean,
+        interactionSource: InteractionSource,
+        colors: TextFieldColors?,
+        textFieldShape: Shape?,
+        focusedIndicatorWidth: Dp,
+        unfocusedIndicatorWidth: Dp,
+    ) {
+        var shouldInvalidate = false
+
+        if (this.enabled != enabled) {
+            this.enabled = enabled
+            shouldInvalidate = true
+        }
+
+        if (this.isError != isError) {
+            this.isError = isError
+            shouldInvalidate = true
+        }
+
+        if (this.interactionSource !== interactionSource) {
+            this.interactionSource = interactionSource
+            trackFocusStateJob?.cancel()
+            trackFocusStateJob = coroutineScope.launch { trackFocusState() }
+        }
+
+        if (this._colors != colors) {
+            this._colors = colors
+            shouldInvalidate = true
+        }
+
+        if (this._shape != textFieldShape) {
+            this._shape = textFieldShape
+            shouldInvalidate = true
+        }
+
+        if (this.focusedIndicatorWidth != focusedIndicatorWidth) {
+            this.focusedIndicatorWidth = focusedIndicatorWidth
+            shouldInvalidate = true
+        }
+
+        if (this.unfocusedIndicatorWidth != unfocusedIndicatorWidth) {
+            this.unfocusedIndicatorWidth = unfocusedIndicatorWidth
+            shouldInvalidate = true
+        }
+
+        if (shouldInvalidate) {
+            invalidateIndicator()
+        }
+    }
+
+    override val shouldAutoInvalidate: Boolean
+        get() = false
+
+    override fun onAttach() {
+        trackFocusStateJob = coroutineScope.launch { trackFocusState() }
+        if (colorAnimatable == null) {
+            val initialColor = colors.indicatorColor(enabled, isError, focused)
+            colorAnimatable =
+                Animatable(
+                    initialValue = initialColor,
+                    typeConverter = Color.VectorConverter(initialColor.colorSpace),
+                )
+        }
+    }
+
+    /** Copied from [InteractionSource.collectIsFocusedAsState] */
+    private suspend fun trackFocusState() {
+        focused = false
+        val focusInteractions = mutableListOf<FocusInteraction.Focus>()
+        interactionSource.interactions.collect { interaction ->
+            when (interaction) {
+                is FocusInteraction.Focus -> focusInteractions.add(interaction)
+                is FocusInteraction.Unfocus -> focusInteractions.remove(interaction.focus)
+            }
+            val isFocused = focusInteractions.isNotEmpty()
+            if (isFocused != focused) {
+                focused = isFocused
+                invalidateIndicator()
+            }
+        }
+    }
+
+    private fun invalidateIndicator() {
+        coroutineScope.launch {
+            colorAnimatable?.animateTo(
+                targetValue = colors.indicatorColor(enabled, isError, focused),
+                animationSpec =
+                    if (enabled) {
+                        currentValueOf(LocalMotionScheme)
+                            .fromToken(MotionSchemeKeyTokens.FastEffects)
+                    } else {
+                        snap()
+                    },
+            )
+        }
+        coroutineScope.launch {
+            widthAnimatable.animateTo(
+                targetValue =
+                    if (focused && enabled) focusedIndicatorWidth else unfocusedIndicatorWidth,
+                animationSpec =
+                    if (enabled) {
+                        currentValueOf(LocalMotionScheme)
+                            .fromToken(MotionSchemeKeyTokens.FastSpatial)
+                    } else {
+                        snap()
+                    },
             )
         }
     }
+
+    private val drawWithCacheModifierNode =
+        delegate(
+            CacheDrawModifierNode {
+                val strokeWidth = widthAnimatable.value.toPx()
+                val textFieldShapePath =
+                    Path().apply {
+                        addOutline(
+                            [email protected](
+                                size,
+                                layoutDirection,
+                                density = this@CacheDrawModifierNode
+                            )
+                        )
+                    }
+                val linePath =
+                    Path().apply {
+                        addRect(
+                            Rect(
+                                left = 0f,
+                                top = size.height - strokeWidth,
+                                right = size.width,
+                                bottom = size.height,
+                            )
+                        )
+                    }
+                val clippedLine = linePath and textFieldShapePath
+
+                onDrawWithContent {
+                    drawContent()
+                    drawPath(
+                        path = clippedLine,
+                        brush = SolidColor(colorAnimatable!!.value),
+                    )
+                }
+            }
+        )
 }
 
 /** Padding from text field top to label top, and from input field bottom to text field bottom */
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
index 6155142..17f68b4 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TextFieldDefaults.kt
@@ -50,11 +50,9 @@
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.takeOrElse
-import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.input.VisualTransformation
 import androidx.compose.ui.unit.Dp
@@ -295,32 +293,16 @@
         focusedIndicatorLineThickness: Dp = FocusedIndicatorThickness,
         unfocusedIndicatorLineThickness: Dp = UnfocusedIndicatorThickness
     ) =
-        composed(
-            inspectorInfo =
-                debugInspectorInfo {
-                    name = "indicatorLine"
-                    properties["enabled"] = enabled
-                    properties["isError"] = isError
-                    properties["interactionSource"] = interactionSource
-                    properties["colors"] = colors
-                    properties["focusedIndicatorLineThickness"] = focusedIndicatorLineThickness
-                    properties["unfocusedIndicatorLineThickness"] = unfocusedIndicatorLineThickness
-                }
-        ) {
-            val resolvedColors = colors ?: colors()
-            val shape = textFieldShape ?: shape
-            val focused = interactionSource.collectIsFocusedAsState().value
-            val stroke =
-                animateBorderStrokeAsState(
-                    enabled,
-                    isError,
-                    focused,
-                    resolvedColors,
-                    focusedIndicatorLineThickness,
-                    unfocusedIndicatorLineThickness
-                )
-            Modifier.drawIndicatorLine(stroke, shape)
-        }
+        this then
+            IndicatorLineElement(
+                enabled = enabled,
+                isError = isError,
+                interactionSource = interactionSource,
+                colors = colors,
+                textFieldShape = textFieldShape,
+                focusedIndicatorLineThickness = focusedIndicatorLineThickness,
+                unfocusedIndicatorLineThickness = unfocusedIndicatorLineThickness,
+            )
 
     /**
      * A decoration box used to create custom text fields based on <a
@@ -492,7 +474,9 @@
      * Creates a [TextFieldColors] that represents the default input text, container, and content
      * colors (including label, placeholder, icons, etc.) used in a [TextField].
      */
-    @Composable fun colors() = MaterialTheme.colorScheme.defaultTextFieldColors
+    @Composable
+    fun colors() =
+        MaterialTheme.colorScheme.defaultTextFieldColors(LocalTextSelectionColors.current)
 
     /**
      * Creates a [TextFieldColors] that represents the default input text, container, and content
@@ -594,145 +578,140 @@
         disabledSuffixColor: Color = Color.Unspecified,
         errorSuffixColor: Color = Color.Unspecified,
     ): TextFieldColors =
-        MaterialTheme.colorScheme.defaultTextFieldColors.copy(
-            focusedTextColor = focusedTextColor,
-            unfocusedTextColor = unfocusedTextColor,
-            disabledTextColor = disabledTextColor,
-            errorTextColor = errorTextColor,
-            focusedContainerColor = focusedContainerColor,
-            unfocusedContainerColor = unfocusedContainerColor,
-            disabledContainerColor = disabledContainerColor,
-            errorContainerColor = errorContainerColor,
-            cursorColor = cursorColor,
-            errorCursorColor = errorCursorColor,
-            textSelectionColors = selectionColors,
-            focusedIndicatorColor = focusedIndicatorColor,
-            unfocusedIndicatorColor = unfocusedIndicatorColor,
-            disabledIndicatorColor = disabledIndicatorColor,
-            errorIndicatorColor = errorIndicatorColor,
-            focusedLeadingIconColor = focusedLeadingIconColor,
-            unfocusedLeadingIconColor = unfocusedLeadingIconColor,
-            disabledLeadingIconColor = disabledLeadingIconColor,
-            errorLeadingIconColor = errorLeadingIconColor,
-            focusedTrailingIconColor = focusedTrailingIconColor,
-            unfocusedTrailingIconColor = unfocusedTrailingIconColor,
-            disabledTrailingIconColor = disabledTrailingIconColor,
-            errorTrailingIconColor = errorTrailingIconColor,
-            focusedLabelColor = focusedLabelColor,
-            unfocusedLabelColor = unfocusedLabelColor,
-            disabledLabelColor = disabledLabelColor,
-            errorLabelColor = errorLabelColor,
-            focusedPlaceholderColor = focusedPlaceholderColor,
-            unfocusedPlaceholderColor = unfocusedPlaceholderColor,
-            disabledPlaceholderColor = disabledPlaceholderColor,
-            errorPlaceholderColor = errorPlaceholderColor,
-            focusedSupportingTextColor = focusedSupportingTextColor,
-            unfocusedSupportingTextColor = unfocusedSupportingTextColor,
-            disabledSupportingTextColor = disabledSupportingTextColor,
-            errorSupportingTextColor = errorSupportingTextColor,
-            focusedPrefixColor = focusedPrefixColor,
-            unfocusedPrefixColor = unfocusedPrefixColor,
-            disabledPrefixColor = disabledPrefixColor,
-            errorPrefixColor = errorPrefixColor,
-            focusedSuffixColor = focusedSuffixColor,
-            unfocusedSuffixColor = unfocusedSuffixColor,
-            disabledSuffixColor = disabledSuffixColor,
-            errorSuffixColor = errorSuffixColor,
-        )
+        MaterialTheme.colorScheme
+            .defaultTextFieldColors(LocalTextSelectionColors.current)
+            .copy(
+                focusedTextColor = focusedTextColor,
+                unfocusedTextColor = unfocusedTextColor,
+                disabledTextColor = disabledTextColor,
+                errorTextColor = errorTextColor,
+                focusedContainerColor = focusedContainerColor,
+                unfocusedContainerColor = unfocusedContainerColor,
+                disabledContainerColor = disabledContainerColor,
+                errorContainerColor = errorContainerColor,
+                cursorColor = cursorColor,
+                errorCursorColor = errorCursorColor,
+                textSelectionColors = selectionColors,
+                focusedIndicatorColor = focusedIndicatorColor,
+                unfocusedIndicatorColor = unfocusedIndicatorColor,
+                disabledIndicatorColor = disabledIndicatorColor,
+                errorIndicatorColor = errorIndicatorColor,
+                focusedLeadingIconColor = focusedLeadingIconColor,
+                unfocusedLeadingIconColor = unfocusedLeadingIconColor,
+                disabledLeadingIconColor = disabledLeadingIconColor,
+                errorLeadingIconColor = errorLeadingIconColor,
+                focusedTrailingIconColor = focusedTrailingIconColor,
+                unfocusedTrailingIconColor = unfocusedTrailingIconColor,
+                disabledTrailingIconColor = disabledTrailingIconColor,
+                errorTrailingIconColor = errorTrailingIconColor,
+                focusedLabelColor = focusedLabelColor,
+                unfocusedLabelColor = unfocusedLabelColor,
+                disabledLabelColor = disabledLabelColor,
+                errorLabelColor = errorLabelColor,
+                focusedPlaceholderColor = focusedPlaceholderColor,
+                unfocusedPlaceholderColor = unfocusedPlaceholderColor,
+                disabledPlaceholderColor = disabledPlaceholderColor,
+                errorPlaceholderColor = errorPlaceholderColor,
+                focusedSupportingTextColor = focusedSupportingTextColor,
+                unfocusedSupportingTextColor = unfocusedSupportingTextColor,
+                disabledSupportingTextColor = disabledSupportingTextColor,
+                errorSupportingTextColor = errorSupportingTextColor,
+                focusedPrefixColor = focusedPrefixColor,
+                unfocusedPrefixColor = unfocusedPrefixColor,
+                disabledPrefixColor = disabledPrefixColor,
+                errorPrefixColor = errorPrefixColor,
+                focusedSuffixColor = focusedSuffixColor,
+                unfocusedSuffixColor = unfocusedSuffixColor,
+                disabledSuffixColor = disabledSuffixColor,
+                errorSuffixColor = errorSuffixColor,
+            )
 
-    internal val ColorScheme.defaultTextFieldColors: TextFieldColors
-        @Composable
-        get() {
-            return defaultTextFieldColorsCached?.let { cachedColors ->
-                val localTextSelectionColors = LocalTextSelectionColors.current
-                if (cachedColors.textSelectionColors == localTextSelectionColors) {
-                    cachedColors
-                } else {
-                    cachedColors.copy(textSelectionColors = localTextSelectionColors).also {
-                        defaultTextFieldColorsCached = it
-                    }
+    internal fun ColorScheme.defaultTextFieldColors(
+        localTextSelectionColors: TextSelectionColors
+    ): TextFieldColors {
+        return defaultTextFieldColorsCached?.let { cachedColors ->
+            if (cachedColors.textSelectionColors == localTextSelectionColors) {
+                cachedColors
+            } else {
+                cachedColors.copy(textSelectionColors = localTextSelectionColors).also {
+                    defaultTextFieldColorsCached = it
                 }
             }
-                ?: TextFieldColors(
-                        focusedTextColor = fromToken(FilledTextFieldTokens.FocusInputColor),
-                        unfocusedTextColor = fromToken(FilledTextFieldTokens.InputColor),
-                        disabledTextColor =
-                            fromToken(FilledTextFieldTokens.DisabledInputColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
-                        errorTextColor = fromToken(FilledTextFieldTokens.ErrorInputColor),
-                        focusedContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
-                        unfocusedContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
-                        disabledContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
-                        errorContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
-                        cursorColor = fromToken(FilledTextFieldTokens.CaretColor),
-                        errorCursorColor = fromToken(FilledTextFieldTokens.ErrorFocusCaretColor),
-                        textSelectionColors = LocalTextSelectionColors.current,
-                        focusedIndicatorColor =
-                            fromToken(FilledTextFieldTokens.FocusActiveIndicatorColor),
-                        unfocusedIndicatorColor =
-                            fromToken(FilledTextFieldTokens.ActiveIndicatorColor),
-                        disabledIndicatorColor =
-                            fromToken(FilledTextFieldTokens.DisabledActiveIndicatorColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledActiveIndicatorOpacity),
-                        errorIndicatorColor =
-                            fromToken(FilledTextFieldTokens.ErrorActiveIndicatorColor),
-                        focusedLeadingIconColor =
-                            fromToken(FilledTextFieldTokens.FocusLeadingIconColor),
-                        unfocusedLeadingIconColor =
-                            fromToken(FilledTextFieldTokens.LeadingIconColor),
-                        disabledLeadingIconColor =
-                            fromToken(FilledTextFieldTokens.DisabledLeadingIconColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledLeadingIconOpacity),
-                        errorLeadingIconColor =
-                            fromToken(FilledTextFieldTokens.ErrorLeadingIconColor),
-                        focusedTrailingIconColor =
-                            fromToken(FilledTextFieldTokens.FocusTrailingIconColor),
-                        unfocusedTrailingIconColor =
-                            fromToken(FilledTextFieldTokens.TrailingIconColor),
-                        disabledTrailingIconColor =
-                            fromToken(FilledTextFieldTokens.DisabledTrailingIconColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledTrailingIconOpacity),
-                        errorTrailingIconColor =
-                            fromToken(FilledTextFieldTokens.ErrorTrailingIconColor),
-                        focusedLabelColor = fromToken(FilledTextFieldTokens.FocusLabelColor),
-                        unfocusedLabelColor = fromToken(FilledTextFieldTokens.LabelColor),
-                        disabledLabelColor =
-                            fromToken(FilledTextFieldTokens.DisabledLabelColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledLabelOpacity),
-                        errorLabelColor = fromToken(FilledTextFieldTokens.ErrorLabelColor),
-                        focusedPlaceholderColor =
-                            fromToken(FilledTextFieldTokens.InputPlaceholderColor),
-                        unfocusedPlaceholderColor =
-                            fromToken(FilledTextFieldTokens.InputPlaceholderColor),
-                        disabledPlaceholderColor =
-                            fromToken(FilledTextFieldTokens.DisabledInputColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
-                        errorPlaceholderColor =
-                            fromToken(FilledTextFieldTokens.InputPlaceholderColor),
-                        focusedSupportingTextColor =
-                            fromToken(FilledTextFieldTokens.FocusSupportingColor),
-                        unfocusedSupportingTextColor =
-                            fromToken(FilledTextFieldTokens.SupportingColor),
-                        disabledSupportingTextColor =
-                            fromToken(FilledTextFieldTokens.DisabledSupportingColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledSupportingOpacity),
-                        errorSupportingTextColor =
-                            fromToken(FilledTextFieldTokens.ErrorSupportingColor),
-                        focusedPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
-                        unfocusedPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
-                        disabledPrefixColor =
-                            fromToken(FilledTextFieldTokens.InputPrefixColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
-                        errorPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
-                        focusedSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
-                        unfocusedSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
-                        disabledSuffixColor =
-                            fromToken(FilledTextFieldTokens.InputSuffixColor)
-                                .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
-                        errorSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
-                    )
-                    .also { defaultTextFieldColorsCached = it }
         }
+            ?: TextFieldColors(
+                    focusedTextColor = fromToken(FilledTextFieldTokens.FocusInputColor),
+                    unfocusedTextColor = fromToken(FilledTextFieldTokens.InputColor),
+                    disabledTextColor =
+                        fromToken(FilledTextFieldTokens.DisabledInputColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
+                    errorTextColor = fromToken(FilledTextFieldTokens.ErrorInputColor),
+                    focusedContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
+                    unfocusedContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
+                    disabledContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
+                    errorContainerColor = fromToken(FilledTextFieldTokens.ContainerColor),
+                    cursorColor = fromToken(FilledTextFieldTokens.CaretColor),
+                    errorCursorColor = fromToken(FilledTextFieldTokens.ErrorFocusCaretColor),
+                    textSelectionColors = localTextSelectionColors,
+                    focusedIndicatorColor =
+                        fromToken(FilledTextFieldTokens.FocusActiveIndicatorColor),
+                    unfocusedIndicatorColor = fromToken(FilledTextFieldTokens.ActiveIndicatorColor),
+                    disabledIndicatorColor =
+                        fromToken(FilledTextFieldTokens.DisabledActiveIndicatorColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledActiveIndicatorOpacity),
+                    errorIndicatorColor =
+                        fromToken(FilledTextFieldTokens.ErrorActiveIndicatorColor),
+                    focusedLeadingIconColor =
+                        fromToken(FilledTextFieldTokens.FocusLeadingIconColor),
+                    unfocusedLeadingIconColor = fromToken(FilledTextFieldTokens.LeadingIconColor),
+                    disabledLeadingIconColor =
+                        fromToken(FilledTextFieldTokens.DisabledLeadingIconColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledLeadingIconOpacity),
+                    errorLeadingIconColor = fromToken(FilledTextFieldTokens.ErrorLeadingIconColor),
+                    focusedTrailingIconColor =
+                        fromToken(FilledTextFieldTokens.FocusTrailingIconColor),
+                    unfocusedTrailingIconColor = fromToken(FilledTextFieldTokens.TrailingIconColor),
+                    disabledTrailingIconColor =
+                        fromToken(FilledTextFieldTokens.DisabledTrailingIconColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledTrailingIconOpacity),
+                    errorTrailingIconColor =
+                        fromToken(FilledTextFieldTokens.ErrorTrailingIconColor),
+                    focusedLabelColor = fromToken(FilledTextFieldTokens.FocusLabelColor),
+                    unfocusedLabelColor = fromToken(FilledTextFieldTokens.LabelColor),
+                    disabledLabelColor =
+                        fromToken(FilledTextFieldTokens.DisabledLabelColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledLabelOpacity),
+                    errorLabelColor = fromToken(FilledTextFieldTokens.ErrorLabelColor),
+                    focusedPlaceholderColor =
+                        fromToken(FilledTextFieldTokens.InputPlaceholderColor),
+                    unfocusedPlaceholderColor =
+                        fromToken(FilledTextFieldTokens.InputPlaceholderColor),
+                    disabledPlaceholderColor =
+                        fromToken(FilledTextFieldTokens.DisabledInputColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
+                    errorPlaceholderColor = fromToken(FilledTextFieldTokens.InputPlaceholderColor),
+                    focusedSupportingTextColor =
+                        fromToken(FilledTextFieldTokens.FocusSupportingColor),
+                    unfocusedSupportingTextColor = fromToken(FilledTextFieldTokens.SupportingColor),
+                    disabledSupportingTextColor =
+                        fromToken(FilledTextFieldTokens.DisabledSupportingColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledSupportingOpacity),
+                    errorSupportingTextColor =
+                        fromToken(FilledTextFieldTokens.ErrorSupportingColor),
+                    focusedPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
+                    unfocusedPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
+                    disabledPrefixColor =
+                        fromToken(FilledTextFieldTokens.InputPrefixColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
+                    errorPrefixColor = fromToken(FilledTextFieldTokens.InputPrefixColor),
+                    focusedSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
+                    unfocusedSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
+                    disabledSuffixColor =
+                        fromToken(FilledTextFieldTokens.InputSuffixColor)
+                            .copy(alpha = FilledTextFieldTokens.DisabledInputOpacity),
+                    errorSuffixColor = fromToken(FilledTextFieldTokens.InputSuffixColor),
+                )
+                .also { defaultTextFieldColorsCached = it }
+    }
 
     @Deprecated(
         level = DeprecationLevel.HIDDEN,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/FloatProducer.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/FloatProducer.kt
new file mode 100644
index 0000000..483bf8a
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/FloatProducer.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.compose.material3.internal
+
+/**
+ * Alternative to `() -> Float` but avoids boxing.
+ *
+ * !!! Do not use in public APIs !!!
+ */
+internal fun interface FloatProducer {
+    /** Returns the Float. */
+    operator fun invoke(): Float
+}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
index 7bfd347..3c48ab1 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
@@ -36,6 +36,7 @@
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.LoadingIndicatorDefaults
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.internal.FloatProducer
 import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
 import androidx.compose.material3.tokens.ElevationTokens
 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
@@ -705,7 +706,7 @@
 /** The default pull indicator for [PullToRefreshBox] */
 @Composable
 private fun CircularArrowProgressIndicator(
-    progress: () -> Float,
+    progress: FloatProducer,
     color: Color,
 ) {
     val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
diff --git a/compose/runtime/runtime-saveable/samples/build.gradle b/compose/runtime/runtime-saveable/samples/build.gradle
index 3bcc90b..cda4890 100644
--- a/compose/runtime/runtime-saveable/samples/build.gradle
+++ b/compose/runtime/runtime-saveable/samples/build.gradle
@@ -36,12 +36,12 @@
 
     implementation(libs.kotlinStdlib)
 
-    compileOnly project(":annotation:annotation-sampled")
+    compileOnly(project(":annotation:annotation-sampled"))
 
     implementation "androidx.compose.foundation:foundation:1.2.1"
     implementation "androidx.compose.material:material:1.2.1"
-    implementation project(":compose:runtime:runtime")
-    implementation project(":compose:runtime:runtime-saveable")
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:runtime:runtime-saveable"))
 }
 
 androidx {
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
index 3d420c5..9088371f 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
@@ -258,7 +258,6 @@
 }
 
 internal class RecordingApplier<N>(root: N) : Applier<N> {
-    private val stack = mutableObjectListOf<N>()
     private val operations = mutableIntListOf()
     private val instances = mutableObjectListOf<Any?>()
 
@@ -267,13 +266,10 @@
     override fun down(node: N) {
         operations.add(DOWN)
         instances.add(node)
-        stack.add(current)
-        current = node
     }
 
     override fun up() {
         operations.add(UP)
-        current = stack.removeAt(stack.size - 1)
     }
 
     override fun remove(index: Int, count: Int) {
diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/reflect/ComposableMethod.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/reflect/ComposableMethod.jvm.kt
index b34353b..96a6404 100644
--- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/reflect/ComposableMethod.jvm.kt
+++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/reflect/ComposableMethod.jvm.kt
@@ -100,8 +100,7 @@
 
     /** Returns method parameters excluding the utility Compose-specific parameters. */
     val parameters: Array<Parameter>
-        @Suppress("ClassVerificationFailure", "NewApi")
-        get() = method.parameters.copyOfRange(0, composableInfo.realParamsCount)
+        @Suppress("NewApi") get() = method.parameters.copyOfRange(0, composableInfo.realParamsCount)
 
     /** Returns method parameters types excluding the utility Compose-specific parameters. */
     val parameterTypes: Array<Class<*>>
diff --git a/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/reflect/ComposableMethodTest.kt b/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/reflect/ComposableMethodTest.kt
index 2eba387..af980a6 100644
--- a/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/reflect/ComposableMethodTest.kt
+++ b/compose/runtime/runtime/src/nonEmulatorJvmTest/kotlin/androidx/compose/runtime/reflect/ComposableMethodTest.kt
@@ -362,7 +362,7 @@
         assertEquals(0, composableMethod.asComposableMethod()!!.parameterCount)
     }
 
-    @Suppress("ClassVerificationFailure", "NewApi")
+    @Suppress("NewApi")
     @Throws(NoSuchMethodException::class)
     @Test
     fun test_realParameters_returns_correct_parameters() {
diff --git a/compose/ui/ui-graphics/benchmark/build.gradle b/compose/ui/ui-graphics/benchmark/build.gradle
index 8ad2df7..6f9f2ec 100644
--- a/compose/ui/ui-graphics/benchmark/build.gradle
+++ b/compose/ui/ui-graphics/benchmark/build.gradle
@@ -23,13 +23,13 @@
 }
 
 dependencies {
-    implementation project(":compose:foundation:foundation")
-    implementation project(":compose:runtime:runtime")
-    implementation project(":compose:benchmark-utils")
-    implementation project(":compose:ui:ui")
+    implementation(project(":compose:foundation:foundation"))
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:benchmark-utils"))
+    implementation(project(":compose:ui:ui"))
     implementation(libs.kotlinStdlib)
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
     androidTestImplementation(libs.testRules)
 }
 
diff --git a/compose/ui/ui-graphics/benchmark/test/build.gradle b/compose/ui/ui-graphics/benchmark/test/build.gradle
index 517f004..bbaf11f 100644
--- a/compose/ui/ui-graphics/benchmark/test/build.gradle
+++ b/compose/ui/ui-graphics/benchmark/test/build.gradle
@@ -23,12 +23,12 @@
 
 dependencies {
 
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:test-utils")
-    androidTestImplementation project(":compose:ui:ui")
-    androidTestImplementation project(":compose:ui:ui-graphics")
-    androidTestImplementation project(":compose:ui:ui-graphics:ui-graphics-benchmark")
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:test-utils"))
+    androidTestImplementation(project(":compose:ui:ui"))
+    androidTestImplementation(project(":compose:ui:ui-graphics"))
+    androidTestImplementation(project(":compose:ui:ui-graphics:ui-graphics-benchmark"))
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
diff --git a/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt b/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
index 52a10ab..789e0b2 100644
--- a/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
+++ b/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
@@ -166,6 +166,12 @@
         assertThat(DEBUG).isFalse()
     }
 
+    @Test // regression test for b/383639244
+    fun noViews() {
+        val builder = LayoutInspectorTree()
+        builder.convert(emptyList())
+    }
+
     @Test
     fun buildTree() {
         val slotTableRecord = CompositionDataRecord.create()
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt
index 4b7e817..ed49a53 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt
@@ -18,6 +18,7 @@
 
 import android.view.View
 import androidx.collection.LongObjectMap
+import androidx.collection.emptyLongObjectMap
 import androidx.collection.mutableIntObjectMapOf
 import androidx.collection.mutableLongObjectMapOf
 import androidx.compose.runtime.tooling.CompositionData
@@ -54,6 +55,9 @@
     /** Converts the [CompositionData] held by [views] into a list of root nodes per view id. */
     fun convert(views: List<View>): LongObjectMap<MutableList<InspectorNode>> {
         clear()
+        if (views.isEmpty()) {
+            return emptyLongObjectMap()
+        }
         val defaultView = views.first()
         builderData.setDensity(defaultView)
         val defaultViewId = defaultView.uniqueDrawingId
diff --git a/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt b/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt
index d345fcb..c260944 100644
--- a/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt
+++ b/compose/ui/ui-test/samples/src/main/java/androidx/compose/ui/test/samples/DeviceConfigurationOverrideSamples.kt
@@ -105,7 +105,6 @@
     }
 }
 
-@Suppress("ClassVerificationFailure") // Only used in sample
 @Sampled
 @Composable
 fun DeviceConfigurationOverrideRoundScreenSample() {
diff --git a/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/LayoutCoordinatesHelperTest.kt b/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/LayoutCoordinatesHelperTest.kt
index 5d97f9c..fac5641 100644
--- a/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/LayoutCoordinatesHelperTest.kt
+++ b/compose/ui/ui-test/src/androidInstrumentedTest/kotlin/androidx/compose/ui/test/LayoutCoordinatesHelperTest.kt
@@ -29,8 +29,6 @@
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.getValue
@@ -267,42 +265,6 @@
         rule.runOnIdle { assertEquals(calculateExpectedIntOffset(alignment), targetOffset.value) }
     }
 
-    @Test
-    fun onPlacedCalledOnReuseInsideLazyColumn() {
-        lateinit var density: Density
-        val items = 200
-        val visibleItems = 2
-        val itemSize = 50.dp
-        val invocations = arrayOf(0, 0)
-
-        // It's important to share lambda across all iterations
-        val placedCallback0: (LayoutCoordinates) -> Unit = { invocations[0] = invocations[0] + 1 }
-        val placedCallback1: (LayoutCoordinates) -> Unit = { invocations[1] = invocations[1] + 1 }
-        val scrollState = LazyListState()
-        rule.setContent {
-            density = LocalDensity.current
-            LazyColumn(Modifier.size(itemSize, itemSize * visibleItems), scrollState) {
-                items(items) {
-                    Box(Modifier.size(itemSize).onPlaced(placedCallback0)) {
-                        Box(Modifier.size(itemSize).onPlaced(placedCallback1))
-                    }
-                }
-            }
-        }
-
-        var expectedInvocations = visibleItems
-        val delta = with(density) { (itemSize * visibleItems).toPx() }
-        repeat(items / visibleItems) {
-            rule.runOnIdle {
-                assertThat(invocations[0]).isAtLeast(expectedInvocations)
-                assertThat(invocations[1]).isAtLeast(expectedInvocations)
-
-                scrollState.dispatchRawDelta(delta)
-                expectedInvocations += visibleItems
-            }
-        }
-    }
-
     private fun Modifier.animatePlacement(
         targetOffset: MutableState<Offset>,
         alignment: () -> Alignment
diff --git a/compose/ui/ui-text/benchmark/build.gradle b/compose/ui/ui-text/benchmark/build.gradle
index ae3f95f..19f23c8 100644
--- a/compose/ui/ui-text/benchmark/build.gradle
+++ b/compose/ui/ui-text/benchmark/build.gradle
@@ -23,17 +23,17 @@
 }
 
 dependencies {
-    implementation project(":benchmark:benchmark-junit4")
-    implementation project(":compose:runtime:runtime")
-    implementation project(":compose:ui:ui-test-junit4")
+    implementation(project(":benchmark:benchmark-junit4"))
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:ui:ui-test-junit4"))
     implementation(libs.kotlinStdlib)
     implementation(libs.kotlinReflect)
     implementation(libs.testRules)
     implementation(libs.junit)
 
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":compose:ui:ui")
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":compose:ui:ui"))
     androidTestImplementation(libs.kotlinTestCommon)
     androidTestImplementation(libs.truth)
 }
diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutTest.kt
index 9d860cb..e061576 100644
--- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutTest.kt
+++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/TextLayoutTest.kt
@@ -35,6 +35,7 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
+@OptIn(InternalPlatformTextApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
 class TextLayoutTest {
@@ -372,112 +373,23 @@
                 text = "aA",
                 fontSize = fontSize,
                 lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = false,
-                trimLastLineBottom = false
+                preserveMinimumHeight = false
             )
 
         val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
+        val expectedPadding = ((fontSize - lineHeight) / 2).toInt()
 
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
+        assertThat(layout.topPadding).isEqualTo(expectedPadding)
+        assertThat(layout.bottomPadding).isEqualTo(expectedPadding)
         assertThat(layout.height).isEqualTo(fontSize.toInt())
         assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(0)).isEqualTo(fontSize.toInt())
+        assertThat(layout.getLineBottom(0)).isEqualTo(layout.height)
         assertThat(layout.getLineBaseline(0)).isEqualTo(-defaultFontMetrics.ascent.toFloat())
         assertThat(layout.getLineForVertical(0)).isEqualTo(0)
         assertThat(layout.getLineForVertical(layout.height)).isEqualTo(0)
     }
 
     @Test
-    fun small_lineheight_allows_clip_when_trimFirstLine_single_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = true,
-                trimLastLineBottom = false
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        // We divide by 2 because the default topRatio is 0.5f in TextLayoutWithSmallLineHeight
-        val diff = (fontSize - lineHeight) / 2
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo((fontSize - diff).toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(0)).isEqualTo((fontSize - diff).toInt())
-        assertThat(layout.getLineBaseline(0))
-            .isEqualTo(-(defaultFontMetrics.descent - diff - lineHeight))
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(0)
-    }
-
-    @Test
-    fun small_lineheight_allows_clip_when_trimLastLine_single_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = false,
-                trimLastLineBottom = true
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        // We divide by 2 because the default topRatio is 0.5f in TextLayoutWithSmallLineHeight
-        val diff = (fontSize - lineHeight) / 2
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo((fontSize - diff).toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(0)).isEqualTo((fontSize - diff).toInt())
-        assertThat(layout.getLineBaseline(0)).isEqualTo(-defaultFontMetrics.ascent)
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(0)
-    }
-
-    @Test
-    fun small_lineheight_allows_clip_when_trimFirstLine_trimLastLine_single_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = true,
-                trimLastLineBottom = true
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        // We divide by 2 because the default topRatio is 0.5f in TextLayoutWithSmallLineHeight
-        val diff = (fontSize - lineHeight) / 2
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo(lineHeight.toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(0)).isEqualTo(lineHeight.toInt())
-        assertThat(layout.getLineBaseline(0)).isEqualTo(-(defaultFontMetrics.ascent + diff))
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(0)
-    }
-
-    @Test
     fun small_lineheight_prevents_clip_multi_line() {
         val fontSize = 120f
         val lineHeight = 60f
@@ -486,16 +398,14 @@
                 text = "aA\naA\naA",
                 fontSize = fontSize,
                 lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = false,
-                trimLastLineBottom = false
+                preserveMinimumHeight = false
             )
 
         val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
         val expectedPadding = ((fontSize - lineHeight) / 2).toInt()
 
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
+        assertThat(layout.topPadding).isEqualTo(expectedPadding)
+        assertThat(layout.bottomPadding).isEqualTo(expectedPadding)
         assertThat(layout.height).isEqualTo((3 * lineHeight + 2 * expectedPadding).toInt())
         assertThat(layout.getLineTop(0)).isEqualTo(0)
         assertThat(layout.getLineBaseline(0)).isEqualTo(-defaultFontMetrics.ascent)
@@ -505,90 +415,6 @@
     }
 
     @Test
-    fun small_lineheight_allows_clip_when_trimFirstLine_multi_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA\naA\naA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = true,
-                trimLastLineBottom = false
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        val expectedPadding = ((fontSize - lineHeight) / 2).toInt()
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo((2 * lineHeight + fontSize - expectedPadding).toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBaseline(0))
-            .isEqualTo(-(defaultFontMetrics.ascent + expectedPadding))
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(2)).isEqualTo(layout.height)
-        assertThat(layout.getLineBaseline(2)).isEqualTo(layout.height - defaultFontMetrics.descent)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(2)
-    }
-
-    @Test
-    fun small_lineheight_allows_clip_when_trimLastLine_multi_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA\naA\naA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = false,
-                trimLastLineBottom = true
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        val expectedPadding = ((fontSize - lineHeight) / 2).toInt()
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo((2 * lineHeight + fontSize - expectedPadding).toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBaseline(0)).isEqualTo(-defaultFontMetrics.ascent)
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(2)).isEqualTo(layout.height)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(2)
-    }
-
-    @Test
-    fun small_lineheight_allows_clip_when_trimFirstLine_trimLastLine_multi_line() {
-        val fontSize = 120f
-        val lineHeight = 60f
-        val layout =
-            TextLayoutWithSmallLineHeight(
-                text = "aA\naA\naA",
-                fontSize = fontSize,
-                lineHeight = lineHeight,
-                preserveMinimumHeight = false,
-                trimFirstLineTop = true,
-                trimLastLineBottom = true
-            )
-
-        val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
-        val expectedPadding = ((fontSize - lineHeight) / 2).toInt()
-
-        assertThat(layout.topPadding).isEqualTo(0)
-        assertThat(layout.bottomPadding).isEqualTo(0)
-        assertThat(layout.height).isEqualTo((3 * lineHeight).toInt())
-        assertThat(layout.getLineTop(0)).isEqualTo(0)
-        assertThat(layout.getLineBaseline(0))
-            .isEqualTo(-(defaultFontMetrics.ascent + expectedPadding))
-        assertThat(layout.getLineForVertical(0)).isEqualTo(0)
-        assertThat(layout.getLineBottom(2)).isEqualTo(layout.height)
-        assertThat(layout.getLineForVertical(layout.height)).isEqualTo(2)
-    }
-
-    @Test
     fun small_lineheight_prevents_clip_multi_line_preserve_linespacing() {
         val fontSize = 120f
         val specifyLineHeight = 60f
@@ -598,9 +424,7 @@
                 text = "aA\naA\naA",
                 fontSize = fontSize,
                 lineHeight = specifyLineHeight,
-                preserveMinimumHeight = true,
-                trimFirstLineTop = false,
-                trimLastLineBottom = false
+                preserveMinimumHeight = true
             )
         val defaultFontMetrics = createTextPaint(fontSize).fontMetricsInt
         val expectedPadding = ((fontSize - systemPreferredLineHeight) / 2).toInt()
@@ -620,9 +444,7 @@
         text: CharSequence,
         fontSize: Float,
         lineHeight: Float,
-        preserveMinimumHeight: Boolean,
-        trimFirstLineTop: Boolean,
-        trimLastLineBottom: Boolean,
+        preserveMinimumHeight: Boolean
     ): TextLayout {
         val textPaint = createTextPaint(fontSize)
         val spannable = SpannableString(text)
@@ -631,8 +453,8 @@
                 lineHeight = lineHeight,
                 startIndex = 0,
                 endIndex = text.length,
-                trimFirstLineTop = trimFirstLineTop,
-                trimLastLineBottom = trimLastLineBottom,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
                 topRatio = 0.5f,
                 preserveMinimumHeight = preserveMinimumHeight,
             ),
diff --git a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpanTest.kt b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpanTest.kt
index 84a3a57..5a330ef 100644
--- a/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpanTest.kt
+++ b/compose/ui/ui-text/src/androidInstrumentedTest/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpanTest.kt
@@ -31,6 +31,7 @@
 private const val MultiLineStartIndex = 0
 private const val MultiLineEndIndex = 3
 
+@OptIn(InternalPlatformTextApi::class)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
 class LineHeightStyleSpanTest {
@@ -83,12 +84,12 @@
 
     @Test
     fun singleLine_topRatio_0_trimFirstLineTop_false_trimLastLineBottom_false_preserve_no() {
-        singleLine_topRatio_0_trimFirstLineTop_false_trimLastLineBottom_false(false)
+        negative_line_height_does_not_chage_the_values(false)
     }
 
     @Test
     fun singleLine_topRatio_0_trimFirstLineTop_false_trimLastLineBottom_false_preserve_yes() {
-        singleLine_topRatio_0_trimFirstLineTop_false_trimLastLineBottom_false(true)
+        negative_line_height_does_not_chage_the_values(true)
     }
 
     private fun singleLine_topRatio_0_trimFirstLineTop_false_trimLastLineBottom_true(
@@ -1141,6 +1142,154 @@
         multiLine_topRatio_proportional_trimFirstLineTop_true_trimLastLineBottom_true(true)
     }
 
+    /* first ascent & last descent diff */
+
+    private fun singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(
+        preserve: Boolean
+    ) {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createSingleLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                newLineHeight = fontMetrics.doubleLineHeight(),
+                preserveMinimumHeight = preserve,
+            )
+
+        span.runFirstLine(fontMetrics)
+
+        val halfLeading = fontMetrics.lineHeight() / 2
+        assertThat(span.firstAscentDiff).isEqualTo(halfLeading)
+        assertThat(span.lastDescentDiff).isEqualTo(halfLeading)
+    }
+
+    @Test
+    fun singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height_preserve_no() {
+        singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(false)
+    }
+
+    @Test
+    fun singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height_preserve_yes() {
+        singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(true)
+    }
+
+    private fun multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(
+        preserve: Boolean
+    ) {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createMultiLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                fontMetrics = fontMetrics,
+                preserveMinimumHeight = preserve,
+            )
+
+        span.runFirstLine(fontMetrics)
+        span.runSecondLine(fontMetrics)
+        span.runLastLine(fontMetrics)
+
+        val halfLeading = fontMetrics.lineHeight() / 2
+        assertThat(span.firstAscentDiff).isEqualTo(halfLeading)
+        assertThat(span.lastDescentDiff).isEqualTo(halfLeading)
+    }
+
+    @Test
+    fun multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height_preserve_no() {
+        multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(false)
+    }
+
+    @Test
+    fun multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height_preserve_yes() {
+        multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_larger_line_height(true)
+    }
+
+    @Test
+    fun singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_smaller_line_height_preserve_no() {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createSingleLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                newLineHeight = fontMetrics.lineHeight() / 2,
+                preserveMinimumHeight = false,
+            )
+
+        span.runFirstLine(fontMetrics)
+
+        val halfLeading = fontMetrics.lineHeight() / -4
+        assertThat(span.firstAscentDiff).isEqualTo(halfLeading)
+        assertThat(span.lastDescentDiff).isEqualTo(halfLeading)
+    }
+
+    @Test
+    fun singleLine_with_firstLineTop_and_lastLineBottom_topRatio_50_smaller_line_height_preserve_yes() {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createSingleLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                newLineHeight = fontMetrics.lineHeight() / 2,
+                preserveMinimumHeight = true,
+            )
+
+        span.runFirstLine(fontMetrics)
+
+        assertThat(span.firstAscentDiff).isEqualTo(0)
+        assertThat(span.lastDescentDiff).isEqualTo(0)
+    }
+
+    @Test
+    fun multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_smaller_line_height_preserve_no() {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createMultiLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                newLineHeight = fontMetrics.lineHeight() / 2,
+                preserveMinimumHeight = false,
+            )
+
+        span.runFirstLine(fontMetrics)
+        span.runSecondLine(fontMetrics)
+        span.runLastLine(fontMetrics)
+
+        val halfLeading = fontMetrics.lineHeight() / -4
+        assertThat(span.firstAscentDiff).isEqualTo(halfLeading)
+        assertThat(span.lastDescentDiff).isEqualTo(halfLeading)
+    }
+
+    @Test
+    fun multiLine_with_firstLineTop_and_lastLineBottom_topRatio_50_smaller_line_height_preserve_yes() {
+        val fontMetrics = createFontMetrics()
+
+        val span =
+            createMultiLineSpan(
+                topRatio = 0.5f,
+                trimFirstLineTop = false,
+                trimLastLineBottom = false,
+                newLineHeight = fontMetrics.lineHeight() / 2,
+                preserveMinimumHeight = true,
+            )
+
+        span.runFirstLine(fontMetrics)
+        span.runSecondLine(fontMetrics)
+        span.runLastLine(fontMetrics)
+
+        assertThat(span.firstAscentDiff).isEqualTo(0)
+        assertThat(span.lastDescentDiff).isEqualTo(0)
+    }
+
     private fun proportionalDescentDiff(fontMetrics: FontMetricsInt): Int {
         val ascent = abs(fontMetrics.ascent.toFloat())
         val ascentRatio = ascent / fontMetrics.lineHeight()
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
index cd7480f..5f5fd9a 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
@@ -73,6 +73,7 @@
 import androidx.compose.ui.text.android.style.getEllipsizedLeftPadding
 import androidx.compose.ui.text.android.style.getEllipsizedRightPadding
 import androidx.compose.ui.text.internal.requirePrecondition
+import kotlin.math.abs
 import kotlin.math.ceil
 import kotlin.math.max
 import kotlin.math.min
@@ -326,29 +327,12 @@
                     layout.getLineEnd(lastLine) != charSequence.length
             }
 
+        val verticalPaddings = getVerticalPaddings()
+
         lineHeightSpans = getLineHeightSpans()
-
-        // Even though it is an array of spans, we know that LineHeightStyle is part of
-        // ParagraphStyle and there can only be one ParagraphStyle per text layout. So there should
-        // also be only one LineHeightStyleSpan in this array. Please check getLastLineMetrics that
-        // uses a similar approach.
-        val shouldForceTrimTop =
-            lineHeightSpans?.firstOrNull()?.let { it.trimFirstLineTop && !it.preserveMinimumHeight }
-                ?: false
-
-        val shouldForceTrimBottom =
-            lineHeightSpans?.firstOrNull()?.let {
-                it.trimLastLineBottom && !it.preserveMinimumHeight
-            } ?: false
-
-        if (shouldForceTrimTop && shouldForceTrimBottom) {
-            topPadding = 0
-            bottomPadding = 0
-        } else {
-            val verticalPaddings = getVerticalPaddings()
-            topPadding = if (shouldForceTrimTop) 0 else verticalPaddings.topPadding
-            bottomPadding = if (shouldForceTrimBottom) 0 else verticalPaddings.bottomPadding
-        }
+        val lineHeightPaddings = lineHeightSpans?.getLineHeightPaddings() ?: ZeroVerticalPadding
+        topPadding = max(verticalPaddings.topPadding, lineHeightPaddings.topPadding)
+        bottomPadding = max(verticalPaddings.bottomPadding, lineHeightPaddings.bottomPadding)
 
         val fontMetrics = getLastLineMetrics(textPaint, frameworkTextDir, lineHeightSpans)
         lastLineExtra =
@@ -818,10 +802,6 @@
         }
     }
 
-    /**
-     * Checks certain configuration values to understand whether the created text layout comes with
-     * fallback line spacing behavior included. Otherwise it needs to be handled here.
-     */
     internal fun isFallbackLinespacingApplied(): Boolean {
         return if (isBoringLayout) {
             BoringLayoutFactory.isFallbackLineSpacingEnabled(layout as BoringLayout)
@@ -988,11 +968,7 @@
         get() = unpackInt2(packedValue)
 }
 
-/**
- * When includeFontPadding is turned off and fallback line spacing is not applied, there remains a
- * chance that tall glyphs may be cut on top and bottom of the text layout. Vertical paddings is a
- * backport of `useFallbackLineSpacing` from the platform.
- */
+@OptIn(InternalPlatformTextApi::class)
 private fun TextLayout.getVerticalPaddings(): VerticalPaddings {
     if (includePadding || isFallbackLinespacingApplied()) return ZeroVerticalPadding
 
@@ -1041,6 +1017,27 @@
 private val ZeroVerticalPadding = VerticalPaddings(0, 0)
 
 @OptIn(InternalPlatformTextApi::class)
+private fun Array<LineHeightStyleSpan>.getLineHeightPaddings(): VerticalPaddings {
+    var firstAscentDiff = 0
+    var lastDescentDiff = 0
+
+    for (span in this) {
+        if (span.firstAscentDiff < 0) {
+            firstAscentDiff = max(firstAscentDiff, abs(span.firstAscentDiff))
+        }
+        if (span.lastDescentDiff < 0) {
+            lastDescentDiff = max(firstAscentDiff, abs(span.lastDescentDiff))
+        }
+    }
+
+    return if (firstAscentDiff == 0 && lastDescentDiff == 0) {
+        ZeroVerticalPadding
+    } else {
+        VerticalPaddings(firstAscentDiff, lastDescentDiff)
+    }
+}
+
+@OptIn(InternalPlatformTextApi::class)
 private fun TextLayout.getLastLineMetrics(
     textPaint: TextPaint,
     frameworkTextDir: TextDirectionHeuristic,
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
index a636910..1114446 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
@@ -45,10 +45,10 @@
     val lineHeight: Float,
     private val startIndex: Int,
     private val endIndex: Int,
-    val trimFirstLineTop: Boolean,
+    private val trimFirstLineTop: Boolean,
     val trimLastLineBottom: Boolean,
     @FloatRange(from = -1.0, to = 1.0) private val topRatio: Float,
-    val preserveMinimumHeight: Boolean,
+    private val preserveMinimumHeight: Boolean,
 ) : android.text.style.LineHeightSpan {
 
     private var firstAscent: Int = Int.MIN_VALUE
@@ -56,6 +56,14 @@
     private var descent: Int = Int.MIN_VALUE
     private var lastDescent: Int = Int.MIN_VALUE
 
+    /** Holds the firstAscent - fontMetricsInt.ascent */
+    var firstAscentDiff = 0
+        private set
+
+    /** Holds the last lastDescent - fontMetricsInt.descent */
+    var lastDescentDiff = 0
+        private set
+
     init {
         checkPrecondition(topRatio in 0f..1f || topRatio == -1f) {
             "topRatio should be in [0..1] range or -1"
@@ -77,6 +85,9 @@
         val isFirstLine = (start == startIndex)
         val isLastLine = (end == endIndex)
 
+        // if single line and should not apply, return
+        if (isFirstLine && isLastLine && trimFirstLineTop && trimLastLineBottom) return
+
         if (firstAscent == Int.MIN_VALUE) {
             calculateTargetMetrics(fontMetricsInt)
         }
@@ -96,6 +107,8 @@
             descent = fontMetricsInt.descent
             firstAscent = ascent
             lastDescent = descent
+            firstAscentDiff = 0
+            lastDescentDiff = 0
             return
         }
 
@@ -118,21 +131,10 @@
         descent = fontMetricsInt.descent + descentDiff
         ascent = descent - ceiledLineHeight
 
-        // when trimming the first line, smaller ascent value means a larger line height.
-        firstAscent =
-            if (trimFirstLineTop) {
-                maxOf(fontMetricsInt.ascent, ascent)
-            } else {
-                minOf(fontMetricsInt.ascent, ascent)
-            }
-
-        // when trimming the last line, larger descent value means a larger line height.
-        lastDescent =
-            if (trimLastLineBottom) {
-                minOf(fontMetricsInt.descent, descent)
-            } else {
-                maxOf(fontMetricsInt.descent, descent)
-            }
+        firstAscent = if (trimFirstLineTop) fontMetricsInt.ascent else ascent
+        lastDescent = if (trimLastLineBottom) fontMetricsInt.descent else descent
+        firstAscentDiff = fontMetricsInt.ascent - firstAscent
+        lastDescentDiff = lastDescent - fontMetricsInt.descent
     }
 
     internal fun copy(
diff --git a/compose/ui/ui-tooling/build.gradle b/compose/ui/ui-tooling/build.gradle
index 08c9f4f1..faaeb0c 100644
--- a/compose/ui/ui-tooling/build.gradle
+++ b/compose/ui/ui-tooling/build.gradle
@@ -116,7 +116,6 @@
     inceptionYear = "2019"
     description = "Compose tooling library. This library exposes information to our tools for better IDE support."
     legacyDisableKotlinStrictApiMode = true
-    metalavaK2UastEnabled = false
     samples(project(":compose:animation:animation:animation-samples"))
     // samples(project(":compose:animation:animation-core:animation-core-samples")) TODO(b/318840087)
     kotlinTarget = KotlinTarget.KOTLIN_1_9
diff --git a/compose/ui/ui-util/build.gradle b/compose/ui/ui-util/build.gradle
index f6ee5c2..e2285aa 100644
--- a/compose/ui/ui-util/build.gradle
+++ b/compose/ui/ui-util/build.gradle
@@ -113,7 +113,6 @@
     inceptionYear = "2020"
     description = "Internal Compose utilities used by other modules"
     legacyDisableKotlinStrictApiMode = true
-    metalavaK2UastEnabled = false
 }
 
 androidxCompose {
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 021d09c..a12b66a 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -135,12 +135,10 @@
     property public final boolean NewNestedScrollFlingDispatchingEnabled;
     property public final boolean isRectTrackingEnabled;
     property public final boolean isSemanticAutofillEnabled;
-    property public final boolean isViewFocusFixEnabled;
     field public static final androidx.compose.ui.ComposeUiFlags INSTANCE;
     field public static boolean NewNestedScrollFlingDispatchingEnabled;
     field public static boolean isRectTrackingEnabled;
     field public static boolean isSemanticAutofillEnabled;
-    field public static boolean isViewFocusFixEnabled;
   }
 
   public final class ComposedModifierKt {
@@ -2577,7 +2575,7 @@
   }
 
   public final class OnGlobalLayoutListenerKt {
-    method public static kotlinx.coroutines.DisposableHandle registerOnGlobalLayoutListener(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
+    method public static kotlinx.coroutines.DisposableHandle registerOnGlobalLayoutListener(androidx.compose.ui.node.DelegatableNode, long throttleMillis, long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
   }
 
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnGloballyPositionedModifier extends androidx.compose.ui.Modifier.Element {
@@ -2588,6 +2586,11 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onGloballyPositioned(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.layout.LayoutCoordinates,kotlin.Unit> onGloballyPositioned);
   }
 
+  public final class OnLayoutRectChangedModifierKt {
+    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onLayoutRectChanged(androidx.compose.ui.Modifier, optional long throttleMillis, optional long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
+    method public static kotlinx.coroutines.DisposableHandle registerOnLayoutRectChanged(androidx.compose.ui.node.DelegatableNode, long throttleMillis, long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
+  }
+
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnPlacedModifier extends androidx.compose.ui.Modifier.Element {
     method public void onPlaced(androidx.compose.ui.layout.LayoutCoordinates coordinates);
   }
@@ -2596,11 +2599,6 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onPlaced(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.layout.LayoutCoordinates,kotlin.Unit> onPlaced);
   }
 
-  public final class OnRectChangedModifierKt {
-    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onRectChanged(androidx.compose.ui.Modifier, optional int throttleMs, optional int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
-    method public static kotlinx.coroutines.DisposableHandle registerOnRectChanged(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
-  }
-
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnRemeasuredModifier extends androidx.compose.ui.Modifier.Element {
     method public void onRemeasured(long size);
   }
@@ -4016,24 +4014,24 @@
 
 package androidx.compose.ui.spatial {
 
-  public final class RectInfo {
+  public final class RelativeLayoutBounds {
     method public java.util.List<androidx.compose.ui.unit.IntRect> calculateOcclusions();
+    method public androidx.compose.ui.unit.IntRect getBoundsInRoot();
+    method public androidx.compose.ui.unit.IntRect getBoundsInScreen();
+    method public androidx.compose.ui.unit.IntRect getBoundsInWindow();
     method public int getHeight();
     method public long getPositionInRoot();
     method public long getPositionInScreen();
     method public long getPositionInWindow();
-    method public androidx.compose.ui.unit.IntRect getRootRect();
-    method public androidx.compose.ui.unit.IntRect getScreenRect();
     method public int getWidth();
-    method public androidx.compose.ui.unit.IntRect getWindowRect();
+    property public final androidx.compose.ui.unit.IntRect boundsInRoot;
+    property public final androidx.compose.ui.unit.IntRect boundsInScreen;
+    property public final androidx.compose.ui.unit.IntRect boundsInWindow;
     property public final int height;
     property public final long positionInRoot;
     property public final long positionInScreen;
     property public final long positionInWindow;
-    property public final androidx.compose.ui.unit.IntRect rootRect;
-    property public final androidx.compose.ui.unit.IntRect screenRect;
     property public final int width;
-    property public final androidx.compose.ui.unit.IntRect windowRect;
   }
 
 }
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 23e436a..258aa94 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -135,12 +135,10 @@
     property public final boolean NewNestedScrollFlingDispatchingEnabled;
     property public final boolean isRectTrackingEnabled;
     property public final boolean isSemanticAutofillEnabled;
-    property public final boolean isViewFocusFixEnabled;
     field public static final androidx.compose.ui.ComposeUiFlags INSTANCE;
     field public static boolean NewNestedScrollFlingDispatchingEnabled;
     field public static boolean isRectTrackingEnabled;
     field public static boolean isSemanticAutofillEnabled;
-    field public static boolean isViewFocusFixEnabled;
   }
 
   public final class ComposedModifierKt {
@@ -2585,7 +2583,7 @@
   }
 
   public final class OnGlobalLayoutListenerKt {
-    method public static kotlinx.coroutines.DisposableHandle registerOnGlobalLayoutListener(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
+    method public static kotlinx.coroutines.DisposableHandle registerOnGlobalLayoutListener(androidx.compose.ui.node.DelegatableNode, long throttleMillis, long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
   }
 
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnGloballyPositionedModifier extends androidx.compose.ui.Modifier.Element {
@@ -2596,6 +2594,11 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onGloballyPositioned(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.layout.LayoutCoordinates,kotlin.Unit> onGloballyPositioned);
   }
 
+  public final class OnLayoutRectChangedModifierKt {
+    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onLayoutRectChanged(androidx.compose.ui.Modifier, optional long throttleMillis, optional long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
+    method public static kotlinx.coroutines.DisposableHandle registerOnLayoutRectChanged(androidx.compose.ui.node.DelegatableNode, long throttleMillis, long debounceMillis, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RelativeLayoutBounds,kotlin.Unit> callback);
+  }
+
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnPlacedModifier extends androidx.compose.ui.Modifier.Element {
     method public void onPlaced(androidx.compose.ui.layout.LayoutCoordinates coordinates);
   }
@@ -2604,11 +2607,6 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onPlaced(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.layout.LayoutCoordinates,kotlin.Unit> onPlaced);
   }
 
-  public final class OnRectChangedModifierKt {
-    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onRectChanged(androidx.compose.ui.Modifier, optional int throttleMs, optional int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
-    method public static kotlinx.coroutines.DisposableHandle registerOnRectChanged(androidx.compose.ui.node.DelegatableNode, int throttleMs, int debounceMs, kotlin.jvm.functions.Function1<? super androidx.compose.ui.spatial.RectInfo,kotlin.Unit> callback);
-  }
-
   @kotlin.jvm.JvmDefaultWithCompatibility public interface OnRemeasuredModifier extends androidx.compose.ui.Modifier.Element {
     method public void onRemeasured(long size);
   }
@@ -4077,24 +4075,24 @@
 
 package androidx.compose.ui.spatial {
 
-  public final class RectInfo {
+  public final class RelativeLayoutBounds {
     method public java.util.List<androidx.compose.ui.unit.IntRect> calculateOcclusions();
+    method public androidx.compose.ui.unit.IntRect getBoundsInRoot();
+    method public androidx.compose.ui.unit.IntRect getBoundsInScreen();
+    method public androidx.compose.ui.unit.IntRect getBoundsInWindow();
     method public int getHeight();
     method public long getPositionInRoot();
     method public long getPositionInScreen();
     method public long getPositionInWindow();
-    method public androidx.compose.ui.unit.IntRect getRootRect();
-    method public androidx.compose.ui.unit.IntRect getScreenRect();
     method public int getWidth();
-    method public androidx.compose.ui.unit.IntRect getWindowRect();
+    property public final androidx.compose.ui.unit.IntRect boundsInRoot;
+    property public final androidx.compose.ui.unit.IntRect boundsInScreen;
+    property public final androidx.compose.ui.unit.IntRect boundsInWindow;
     property public final int height;
     property public final long positionInRoot;
     property public final long positionInScreen;
     property public final long positionInWindow;
-    property public final androidx.compose.ui.unit.IntRect rootRect;
-    property public final androidx.compose.ui.unit.IntRect screenRect;
     property public final int width;
-    property public final androidx.compose.ui.unit.IntRect windowRect;
   }
 
 }
diff --git a/compose/ui/ui/build.gradle b/compose/ui/ui/build.gradle
index 60d27f7..73d3551 100644
--- a/compose/ui/ui/build.gradle
+++ b/compose/ui/ui/build.gradle
@@ -66,7 +66,7 @@
         commonTest {
             dependencies {
                 implementation(libs.kotlinReflect)
-                api project(":compose:ui:ui-util")
+                api(project(":compose:ui:ui-util"))
             }
         }
 
@@ -219,6 +219,7 @@
     metalavaK2UastEnabled = false
     samples(project(":compose:ui:ui:ui-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
 
 android {
@@ -229,12 +230,6 @@
     }
     // TODO(b/345531033)
     experimentalProperties["android.lint.useK2Uast"] = false
-}
-
-// Screenshot tests related setup
-android {
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/compose/ui/ui"
     namespace = "androidx.compose.ui"
     // namespace has to be unique, but default androidx.compose.ui.test package is taken by
     // the androidx.compose.ui:ui-test library
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ClipboardDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ClipboardDemo.kt
index 921e2c0..8c170e5 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ClipboardDemo.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/ClipboardDemo.kt
@@ -188,7 +188,7 @@
     return clipMetadata.clipDescription.hasMimeType("image/*")
 }
 
-@Suppress("ClassVerificationFailure", "DEPRECATION")
+@Suppress("DEPRECATION")
 fun Uri.readImageBitmap(context: Context): ImageBitmap {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
             ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, this))
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/SimpleChatActivity.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/SimpleChatActivity.kt
index b76b3a6..7e69716 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/SimpleChatActivity.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/SimpleChatActivity.kt
@@ -56,7 +56,6 @@
 import androidx.compose.ui.unit.sp
 import kotlinx.coroutines.launch
 
-@SuppressLint("ClassVerificationFailure")
 class SimpleChatActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
index 3492d49..47bd70f 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
@@ -32,6 +32,7 @@
 import androidx.compose.ui.demos.accessibility.ScaffoldSampleScrollDemo
 import androidx.compose.ui.demos.accessibility.ScrollingColumnDemo
 import androidx.compose.ui.demos.accessibility.SimpleRtlLayoutDemo
+import androidx.compose.ui.demos.autofill.AutofillNavigation
 import androidx.compose.ui.demos.autofill.BTFResetCredentialsDemo
 import androidx.compose.ui.demos.autofill.BasicSecureTextFieldAutofillDemo
 import androidx.compose.ui.demos.autofill.BasicTextFieldAutofill
@@ -286,7 +287,8 @@
                 BasicSecureTextFieldAutofillDemo()
             },
             ComposableDemo("S: TextField Autofill") { LegacyTextFieldAutofillDemo() },
-            ComposableDemo("S: OutlinedTextField Autofill") { OutlinedTextFieldAutofillDemo() }
+            ComposableDemo("S: OutlinedTextField Autofill") { OutlinedTextFieldAutofillDemo() },
+            ComposableDemo("Navigation Sample") { AutofillNavigation() }
         )
     )
 
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt
index 7f9613e..0e1fc8a 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/autofill/AutofillNavigationDemo.kt
@@ -34,6 +34,7 @@
 import androidx.compose.material.Button
 import androidx.compose.material.Checkbox
 import androidx.compose.material.Icon
+import androidx.compose.material.LocalTextStyle
 import androidx.compose.material.Scaffold
 import androidx.compose.material.Text
 import androidx.compose.material.icons.Icons
@@ -48,9 +49,9 @@
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.autofill.ContentType
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalAutofillManager
 import androidx.compose.ui.semantics.contentType
 import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.navigation.NavController
@@ -227,13 +228,7 @@
         Spacer(modifier = Modifier.height(16.dp))
         Text(text = "Enter your username and password below:")
         Spacer(modifier = Modifier.height(8.dp))
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.NewUsername
-                }
-        )
+        NavigationDemoTextField(contentType = ContentType.NewUsername)
 
         Spacer(modifier = Modifier.height(8.dp))
         Row(verticalAlignment = Alignment.CenterVertically) {
@@ -243,13 +238,7 @@
 
         if (showPassword) {
             Spacer(modifier = Modifier.height(8.dp))
-            BasicTextField(
-                state = remember { TextFieldState() },
-                modifier =
-                    Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                        contentType = ContentType.NewPassword
-                    }
-            )
+            NavigationDemoTextField(contentType = ContentType.NewPassword)
         }
     }
 }
@@ -269,21 +258,8 @@
         Spacer(modifier = Modifier.height(16.dp))
         Text(text = "Enter your username and password below:")
         Spacer(modifier = Modifier.height(8.dp))
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.Username
-                }
-        )
-
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.Password
-                }
-        )
+        NavigationDemoTextField(contentType = ContentType.Username)
+        NavigationDemoTextField(contentType = ContentType.Password)
     }
 }
 
@@ -297,13 +273,7 @@
         Spacer(modifier = Modifier.height(16.dp))
         Text(text = "Enter your username and password below:")
         Spacer(modifier = Modifier.height(8.dp))
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.NewUsername
-                }
-        )
+        NavigationDemoTextField(contentType = ContentType.NewUsername)
 
         repeat(50) {
             Text(
@@ -313,13 +283,7 @@
             )
         }
 
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.NewPassword
-                }
-        )
+        NavigationDemoTextField(contentType = ContentType.NewPassword)
     }
 }
 
@@ -334,21 +298,8 @@
         Spacer(modifier = Modifier.height(16.dp))
         Text(text = "Enter your username and password below:")
         Spacer(modifier = Modifier.height(8.dp))
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.NewUsername
-                }
-        )
-
-        BasicTextField(
-            state = remember { TextFieldState() },
-            modifier =
-                Modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
-                    contentType = ContentType.NewPassword
-                }
-        )
+        NavigationDemoTextField(contentType = ContentType.NewUsername)
+        NavigationDemoTextField(contentType = ContentType.NewPassword)
 
         repeat(50) {
             Text(
@@ -364,6 +315,23 @@
 // Helper functions
 // ============================================================================================
 
+@Composable
+fun NavigationDemoTextField(
+    modifier: Modifier = Modifier,
+    state: TextFieldState = remember { TextFieldState() },
+    contentType: ContentType,
+    textStyle: TextStyle = LocalTextStyle.current.copy(color = Color.White)
+) {
+    BasicTextField(
+        state = state,
+        modifier =
+            modifier.fillMaxWidth().border(1.dp, Color.LightGray).semantics {
+                this.contentType = contentType
+            },
+        textStyle = textStyle
+    )
+}
+
 /** Template scaffold for the navigation demo with two buttons: forward and backwards. */
 @Composable
 fun TwoButtonNavigationScaffold(
@@ -372,8 +340,6 @@
     backwardRoute: String,
     content: @Composable () -> Unit
 ) {
-    val autofillManager = LocalAutofillManager.current
-
     Scaffold(
         content = { padding ->
             Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -392,11 +358,7 @@
 
                 // Navigation Button forwards
                 Button(
-                    onClick = {
-                        navController.navigate(forwardRoute)
-                        // Navigating forwards should commit an autofill context.
-                        autofillManager?.commit()
-                    },
+                    onClick = { navController.navigate(forwardRoute) },
                     modifier = Modifier.align(Alignment.Start)
                 ) {
                     Icon(
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
index 3e46712..6f44c0a 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
@@ -77,6 +77,7 @@
     @SdkSuppress(minSdkVersion = 26)
     @Test
     fun onProvideAutofillVirtualStructure_populatesViewStructure() {
+        // TODO(b/383201236): Ensure the old API works when the new API is enabled.
         if (isSemanticAutofillEnabled) return
         // Arrange.
         val viewStructure: ViewStructure = FakeViewStructure()
@@ -99,10 +100,11 @@
                     children.add(
                         FakeViewStructure().apply {
                             virtualId = autofillNode.id
+                            autofillId = ownerView.autofillId
+                            autofillType = View.AUTOFILL_TYPE_TEXT
+                            autofillHints = mutableListOf(AUTOFILL_HINT_PERSON_NAME)
                             packageName = currentPackageName
-                            setAutofillType(View.AUTOFILL_TYPE_TEXT)
-                            setAutofillHints(arrayOf(AUTOFILL_HINT_PERSON_NAME))
-                            setDimens(0, 0, 0, 0, 0, 0)
+                            bounds = android.graphics.Rect(0, 0, 0, 0)
                         }
                     )
                 }
@@ -112,6 +114,7 @@
     @SdkSuppress(minSdkVersion = 26)
     @Test
     fun autofill_triggersOnFill() {
+        // TODO(b/383201236): Ensure the old API works when the new API is enabled.
         if (isSemanticAutofillEnabled) return
         // Arrange.
         val expectedValue = "PersonName"
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
index df30b59f..face43c 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutofillManagerTest.kt
@@ -17,30 +17,26 @@
 package androidx.compose.ui.autofill
 
 import android.os.Build
-import android.view.View
 import androidx.annotation.RequiresApi
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
+import androidx.compose.ui.ComposeUiFlags
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.platform.AndroidComposeView
 import androidx.compose.ui.platform.LocalAutofillManager
-import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.contentDataType
 import androidx.compose.ui.semantics.contentType
 import androidx.compose.ui.semantics.editableText
 import androidx.compose.ui.semantics.focused
+import androidx.compose.ui.semantics.onAutofillText
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.test.TestActivity
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
@@ -50,12 +46,15 @@
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
+import kotlin.test.Ignore
 import org.junit.After
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.kotlin.any
 import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
 import org.mockito.kotlin.verify
 import org.mockito.kotlin.verifyNoMoreInteractions
 
@@ -65,15 +64,18 @@
 class AndroidAutofillManagerTest {
     @get:Rule val rule = createAndroidComposeRule<TestActivity>()
 
-    private lateinit var androidComposeView: AndroidComposeView
-    private lateinit var composeView: View
-    private lateinit var autofillManagerMock: AutofillManagerWrapper
-
-    private val autofillEventLoopIntervalMs = 100L
-
     private val height = 200.dp
     private val width = 200.dp
 
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
+    @Before
+    fun enableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = true
+    }
+
     @After
     fun teardown() {
         val instrumentation = InstrumentationRegistry.getInstrumentation()
@@ -85,15 +87,18 @@
                 }
             }
         }
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_empty() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(height, width)
@@ -101,17 +106,162 @@
             )
         }
 
-        rule.runOnIdle { verifyNoMoreInteractions(autofillManagerMock) }
+        rule.runOnIdle { verifyNoMoreInteractions(am) }
+    }
+
+    @Test
+    @SmallTest
+    fun autofillManager_doNotCallCommit_nodesAppeared() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var isVisible by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            if (isVisible) {
+                Box(
+                    Modifier.semantics { contentType = ContentType.Username }
+                        .size(height, width)
+                        .testTag(usernameTag)
+                )
+            }
+        }
+
+        rule.runOnIdle { isVisible = true }
+
+        // `commit` should not be called when an autofillable component appears onscreen.
+        rule.runOnIdle { verify(am, times(0)).commit() }
+    }
+
+    @Test
+    @SmallTest
+    fun autofillManager_doNotCallCommit_autofillTagsAdded() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var isRelatedToAutofill by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            Box(
+                modifier =
+                    if (isRelatedToAutofill)
+                        Modifier.semantics {
+                                contentType = ContentType.Username
+                                contentDataType = ContentDataType.Text
+                            }
+                            .size(height, width)
+                            .testTag(usernameTag)
+                    else Modifier.size(height, width).testTag(usernameTag)
+            )
+        }
+
+        rule.runOnIdle { isRelatedToAutofill = true }
+
+        // `commit` should not be called a component becomes relevant to autofill.
+        rule.runOnIdle { verify(am, times(0)).commit() }
+    }
+
+    @Test
+    @SmallTest
+    fun autofillManager_callCommit_nodesDisappeared() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var revealFirstUsername by mutableStateOf(true)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            if (revealFirstUsername) {
+                Box(
+                    Modifier.semantics { contentType = ContentType.Username }
+                        .size(height, width)
+                        .testTag(usernameTag)
+                )
+            }
+        }
+
+        rule.runOnIdle { revealFirstUsername = false }
+
+        // `commit` should be called when an autofillable component leaves the screen.
+        rule.runOnIdle { verify(am, times(1)).commit() }
+    }
+
+    @Test
+    @SmallTest
+    fun autofillManager_callCommit_nodesDisappearedAndAppeared() {
+        val am: PlatformAutofillManager = mock()
+        val username1Tag = "username_tag"
+        val username2Tag = "username_tag"
+        var revealFirstUsername by mutableStateOf(true)
+        var revealSecondUsername by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            if (revealFirstUsername) {
+                Box(
+                    Modifier.semantics { contentType = ContentType.Username }
+                        .size(height, width)
+                        .testTag(username1Tag)
+                )
+            }
+            if (revealSecondUsername) {
+                Box(
+                    Modifier.semantics { contentType = ContentType.Username }
+                        .size(height, width)
+                        .testTag(username2Tag)
+                )
+            }
+        }
+
+        rule.runOnIdle { revealFirstUsername = false }
+        rule.runOnIdle { revealSecondUsername = true }
+
+        // `commit` should be called when an autofillable component leaves onscreen, even when
+        // another, different autofillable component is added.
+        rule.runOnIdle { verify(am, times(1)).commit() }
+    }
+
+    @Test
+    @SmallTest
+    fun autofillManager_callCommit_nodesBecomeAutofillRelatedAndDisappear() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var isVisible by mutableStateOf(true)
+        var isRelatedToAutofill by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            if (isVisible) {
+                Box(
+                    modifier =
+                        if (isRelatedToAutofill)
+                            Modifier.semantics {
+                                    contentType = ContentType.Username
+                                    contentDataType = ContentDataType.Text
+                                }
+                                .size(height, width)
+                                .testTag(usernameTag)
+                        else Modifier.size(height, width).testTag(usernameTag)
+                )
+            }
+        }
+
+        rule.runOnIdle { isRelatedToAutofill = true }
+        rule.runOnIdle { isVisible = false }
+
+        // `commit` should be called when component becomes autofillable, then leaves the screen.
+        rule.runOnIdle { verify(am, times(1)).commit() }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyValueChanged() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var changeText by mutableStateOf(false)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -125,17 +275,46 @@
 
         rule.runOnIdle { changeText = true }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyValueChanged(any(), any()) }
+        rule.runOnIdle { verify(am).notifyValueChanged(any(), any(), any()) }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyViewEntered_previousFocusFalse() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var hasFocus by mutableStateOf(false)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            Box(
+                Modifier.semantics {
+                        contentType = ContentType.Username
+                        contentDataType = ContentDataType.Text
+                        focused = hasFocus
+                        onAutofillText { true }
+                    }
+                    .size(height, width)
+                    .testTag(usernameTag)
+            )
+        }
+
+        rule.runOnIdle { hasFocus = true }
+
+        rule.runOnIdle { verify(am).notifyViewEntered(any(), any(), any()) }
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun autofillManager_notAutofillable_notifyViewEntered_previousFocusFalse() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var hasFocus by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -149,17 +328,49 @@
 
         rule.runOnIdle { hasFocus = true }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewEntered(any(), any()) }
+        rule.runOnIdle { verifyNoMoreInteractions(am) }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyViewEntered_previousFocusNull() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var hasFocus by mutableStateOf(false)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            Box(
+                modifier =
+                    if (hasFocus)
+                        Modifier.semantics {
+                                contentType = ContentType.Username
+                                contentDataType = ContentDataType.Text
+                                focused = hasFocus
+                                onAutofillText { true }
+                            }
+                            .size(height, width)
+                            .testTag(usernameTag)
+                    else plainVisibleModifier(usernameTag)
+            )
+        }
+
+        rule.runOnIdle { hasFocus = true }
+
+        rule.runOnIdle { verify(am).notifyViewEntered(any(), any(), any()) }
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun autofillManager_notifyViewEntered_itemNotAutofillable() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var hasFocus by mutableStateOf(false)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 modifier =
                     if (hasFocus)
@@ -176,17 +387,46 @@
 
         rule.runOnIdle { hasFocus = true }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewEntered(any(), any()) }
+        rule.runOnIdle { verifyNoMoreInteractions(am) }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyViewExited_previousFocusTrue() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var hasFocus by mutableStateOf(true)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
+            Box(
+                Modifier.semantics {
+                        contentType = ContentType.Username
+                        contentDataType = ContentDataType.Text
+                        focused = hasFocus
+                        onAutofillText { true }
+                    }
+                    .size(height, width)
+                    .testTag(usernameTag)
+            )
+        }
+
+        rule.runOnIdle { hasFocus = false }
+
+        rule.runOnIdle { verify(am).notifyViewExited(any(), any()) }
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun autofillManager_notifyViewExited_previouslyFocusedItemNotAutofillable() {
+        val am: PlatformAutofillManager = mock()
+        val usernameTag = "username_tag"
+        var hasFocus by mutableStateOf(true)
+
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -200,17 +440,20 @@
 
         rule.runOnIdle { hasFocus = false }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewExited(any()) }
+        rule.runOnIdle { verifyNoMoreInteractions(am) }
     }
 
+    @Ignore // TODO(b/383198004): Add support for notifyVisibilityChanged.
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 27)
     fun autofillManager_notifyVisibilityChanged_disappeared() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var isVisible by mutableStateOf(true)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 modifier =
                     if (isVisible) plainVisibleModifier(usernameTag)
@@ -220,17 +463,20 @@
 
         rule.runOnIdle { isVisible = false }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewVisibilityChanged(any(), any()) }
+        rule.runOnIdle { verify(am).notifyViewVisibilityChanged(any(), any(), any()) }
     }
 
+    @Ignore // TODO(b/383198004): Add support for notifyVisibilityChanged.
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 27)
     fun autofillManager_notifyVisibilityChanged_appeared() {
+        val am: PlatformAutofillManager = mock()
         val usernameTag = "username_tag"
         var isVisible by mutableStateOf(false)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 modifier =
                     if (isVisible) plainVisibleModifier(usernameTag)
@@ -240,17 +486,19 @@
 
         rule.runOnIdle { isVisible = true }
 
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewVisibilityChanged(any(), any()) }
+        rule.runOnIdle { verify(am).notifyViewVisibilityChanged(any(), any(), any()) }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyCommit() {
+        val am: PlatformAutofillManager = mock()
         val forwardTag = "forward_button_tag"
         var autofillManager: AutofillManager?
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            (LocalAutofillManager.current as AndroidAutofillManager).platformAutofillManager = am
             autofillManager = LocalAutofillManager.current
             Box(
                 modifier =
@@ -262,18 +510,20 @@
 
         rule.onNodeWithTag(forwardTag).performClick()
 
-        rule.runOnIdle { verify(autofillManagerMock).commit() }
+        rule.runOnIdle { verify(am).commit() }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_notifyCancel() {
+        val am: PlatformAutofillManager = mock()
         val backTag = "back_button_tag"
         var autofillManager: AutofillManager?
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
             autofillManager = LocalAutofillManager.current
+            (autofillManager as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 modifier =
                     Modifier.clickable { autofillManager?.cancel() }
@@ -283,19 +533,58 @@
         }
         rule.onNodeWithTag(backTag).performClick()
 
-        rule.runOnIdle { verify(autofillManagerMock).cancel() }
+        rule.runOnIdle { verify(am).cancel() }
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun autofillManager_requestAutofillAfterFocus() {
+        val am: PlatformAutofillManager = mock()
         val contextMenuTag = "menu_tag"
         var autofillManager: AutofillManager?
         var hasFocus by mutableStateOf(false)
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
             autofillManager = LocalAutofillManager.current
+            (autofillManager as AndroidAutofillManager).platformAutofillManager = am
+            Box(
+                modifier =
+                    if (hasFocus)
+                        Modifier.semantics {
+                                contentType = ContentType.Username
+                                contentDataType = ContentDataType.Text
+                                focused = hasFocus
+                                onAutofillText { true }
+                            }
+                            .clickable { autofillManager?.requestAutofillForActiveElement() }
+                            .size(height, width)
+                            .testTag(contextMenuTag)
+                    else plainVisibleModifier(contextMenuTag)
+            )
+        }
+
+        // `requestAutofill` is always called after an element is focused
+        rule.runOnIdle { hasFocus = true }
+        rule.runOnIdle { verify(am).notifyViewEntered(any(), any(), any()) }
+
+        // then `requestAutofill` is called on that same previously focused element
+        rule.onNodeWithTag(contextMenuTag).performClick()
+        rule.runOnIdle { verify(am).requestAutofill(any(), any(), any()) }
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun autofillManager_notAutofillable_doesNotrequestAutofillAfterFocus() {
+        val am: PlatformAutofillManager = mock()
+        val contextMenuTag = "menu_tag"
+        var autofillManager: AutofillManager?
+        var hasFocus by mutableStateOf(false)
+
+        rule.setContent {
+            autofillManager = LocalAutofillManager.current
+            (autofillManager as AndroidAutofillManager).platformAutofillManager = am
             Box(
                 modifier =
                     if (hasFocus)
@@ -313,11 +602,7 @@
 
         // `requestAutofill` is always called after an element is focused
         rule.runOnIdle { hasFocus = true }
-        rule.runOnIdle { verify(autofillManagerMock).notifyViewEntered(any(), any()) }
-
-        // then `requestAutofill` is called on that same previously focused element
-        rule.onNodeWithTag(contextMenuTag).performClick()
-        rule.runOnIdle { verify(autofillManagerMock).requestAutofill(any(), any()) }
+        rule.runOnIdle { verifyNoMoreInteractions(am) }
     }
 
     // ============================================================================================
@@ -342,25 +627,4 @@
             .size(width, height)
             .testTag(testTag)
     }
-
-    @OptIn(ExperimentalComposeUiApi::class)
-    @RequiresApi(Build.VERSION_CODES.O)
-    private fun ComposeContentTestRule.setContentWithAutofillEnabled(
-        content: @Composable () -> Unit
-    ) {
-        autofillManagerMock = mock()
-
-        setContent {
-            androidComposeView = LocalView.current as AndroidComposeView
-            androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true
-            androidComposeView._autofillManager?.autofillManager = autofillManagerMock
-            isSemanticAutofillEnabled = true
-
-            composeView = LocalView.current
-
-            content()
-        }
-
-        runOnIdle { mainClock.advanceTimeBy(autofillEventLoopIntervalMs) }
-    }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/FakeViewStructure.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/FakeViewStructure.kt
index 73b2b54..aa3ffa8 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/FakeViewStructure.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/FakeViewStructure.kt
@@ -21,109 +21,83 @@
 import android.os.Build
 import android.os.Bundle
 import android.os.LocaleList
-import android.os.Parcel
 import android.view.View
 import android.view.ViewStructure
 import android.view.autofill.AutofillId
 import android.view.autofill.AutofillValue
-import androidx.annotation.GuardedBy
 import androidx.annotation.RequiresApi
 
 /**
  * A fake implementation of [ViewStructure] to use in tests.
  *
- * @param virtualId An ID that is unique for each viewStructure node in the viewStructure tree.
- * @param packageName The package name of the app (Used as an autofill heuristic).
- * @param typeName The type name of the view's identifier, or null if there is none.
- * @param entryName The entry name of the view's identifier, or null if there is none.
- * @param children A list of [ViewStructure]s that are children of the current [ViewStructure].
- * @param bounds The bounds (Dimensions) of the component represented by this [ViewStructure].
- * @param autofillId The [autofillId] for the parent component. The same autofillId is used for
- *   other child components.
- * @param autofillType The data type. Can be one of the following: [View.AUTOFILL_TYPE_DATE],
- *   [View.AUTOFILL_TYPE_LIST], [View.AUTOFILL_TYPE_TEXT], [View.AUTOFILL_TYPE_TOGGLE] or
- *   [View.AUTOFILL_TYPE_NONE].
- * @param autofillHints The autofill hint. If this value not specified, we use heuristics to
- *   determine what data to use while performing autofill.
+ * We use a data class to get an equals and toString implementation. The properties are marked as
+ *
+ * @JvmField so that they don't clash with the set* functions in the ViewStructure interface that
+ *   this class implements.
  */
 @RequiresApi(Build.VERSION_CODES.M)
 internal data class FakeViewStructure(
-    var virtualId: Int = 0,
-    var packageName: String? = null,
-    var typeName: String? = null,
-    var entryName: String? = null,
-    var children: MutableList<FakeViewStructure> = mutableListOf(),
-    var bounds: Rect? = null,
-    private val autofillId: AutofillId? =
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) generateAutofillId() else null,
-    internal var autofillType: Int = View.AUTOFILL_TYPE_NONE,
-    internal var autofillHints: Array<out String> = arrayOf()
+    @JvmField var virtualId: Int = 0,
+    @JvmField var packageName: String? = null,
+    @JvmField var typeName: String? = null,
+    @JvmField var entryName: String? = null,
+    @JvmField var children: MutableList<FakeViewStructure> = mutableListOf(),
+    @JvmField var bounds: Rect? = null,
+    @JvmField var autofillId: AutofillId? = null,
+    @JvmField var isCheckable: Boolean = false,
+    @JvmField var isFocusable: Boolean = false,
+    @JvmField var autofillType: Int = View.AUTOFILL_TYPE_NONE,
+    @JvmField var autofillHints: MutableList<String> = mutableListOf(),
+    @JvmField var activated: Boolean = false,
+    @JvmField var alpha: Float = 1f,
+    @JvmField var autofillOptions: MutableList<CharSequence>? = null,
+    @JvmField var autofillValue: AutofillValue? = null,
+    @JvmField var className: String? = null,
+    @JvmField var contentDescription: CharSequence? = null,
+    @JvmField var dataIsSensitive: Boolean = false,
+    @JvmField var elevation: Float = 0f,
+    @JvmField var hint: CharSequence? = null,
+    @JvmField var htmlInfo: HtmlInfo? = null,
+    @JvmField var inputType: Int = 0,
+    @JvmField var isEnabled: Boolean = false,
+    @JvmField var isAccessibilityFocused: Boolean = false,
+    @JvmField var isChecked: Boolean = false,
+    @JvmField var isClickable: Boolean = false,
+    @JvmField var isContextClickable: Boolean = false,
+    @JvmField var isFocused: Boolean = false,
+    @JvmField var isLongClickable: Boolean = false,
+    @JvmField var isOpaque: Boolean = false,
+    @JvmField var isSelected: Boolean = false,
+    @JvmField var text: CharSequence = "",
+    @JvmField var textLinesCharOffsets: MutableList<Int>? = null,
+    @JvmField var textLinesBaselines: MutableList<Int>? = null,
+    @JvmField var transformation: Matrix? = null,
+    @JvmField var visibility: Int = View.VISIBLE,
+    @JvmField var maxTextLength: Int = -1,
+    @JvmField var webDomain: String? = null,
 ) : ViewStructure() {
-
-    private var activated: Boolean = false
-    private var alpha: Float = 1f
-    private var autofillOptions: Array<CharSequence>? = null
-    private var autofillValue: AutofillValue? = null
-    private var className: String? = null
-    private var contentDescription: CharSequence? = null
-    private var dataIsSensitive: Boolean = false
-    private var elevation: Float = 0f
-    private var extras: Bundle = Bundle()
-    private var hint: CharSequence? = null
-    private var htmlInfo: HtmlInfo? = null
-    private var inputType: Int = 0
-    private var isEnabled: Boolean = true
-    private var isAccessibilityFocused: Boolean = false
-    private var isCheckable: Boolean = false
-    private var isChecked: Boolean = false
-    private var isClickable: Boolean = true
-    private var isContextClickable: Boolean = false
-    private var isFocused: Boolean = false
-    private var isFocusable: Boolean = false
-    private var isLongClickable: Boolean = false
-    private var isOpaque: Boolean = false
-    private var selected: Boolean = false
-    private var text: CharSequence = ""
-    private var textLines: IntArray? = null
-    private var transformation: Matrix? = null
-    private var visibility: Int = View.VISIBLE
-    private var webDomain: String? = null
-
-    internal companion object {
-        @GuardedBy("this") private var previousId = 0
-        private val NO_SESSION = 0
-
-        // Android API level 26 introduced Autofill. Prior to API level 26, no autofill ID will be
-        // provided.
-        @RequiresApi(Build.VERSION_CODES.O)
-        @Synchronized
-        private fun generateAutofillId(): AutofillId {
-            var autofillId: AutofillId? = null
-            useParcel { parcel ->
-                parcel.writeInt(++previousId) // View Id.
-                parcel.writeInt(NO_SESSION) // Flag.
-                parcel.setDataPosition(0)
-                autofillId = AutofillId.CREATOR.createFromParcel(parcel)
-            }
-            return autofillId ?: error("Could not generate autofill id")
-        }
-    }
+    @JvmField var extras: Bundle = Bundle()
 
     override fun getChildCount() = children.count()
 
     override fun addChildCount(childCount: Int): Int {
-        repeat(childCount) { children.add(FakeViewStructure(autofillId = autofillId)) }
-        return children.count() - childCount
+        repeat(childCount) { children.add(FakeViewStructure()) }
+        return children.size - childCount
     }
 
     override fun newChild(index: Int): FakeViewStructure {
-        if (index >= children.count()) error("Call addChildCount() before calling newChild()")
+        if (index >= children.size) error("Call addChildCount() before calling newChild()")
         return children[index]
     }
 
-    override fun getAutofillId() = autofillId
+    override fun getAutofillId(): AutofillId? = autofillId
+
+    override fun setAutofillId(id: AutofillId) {
+        autofillId = id
+    }
 
     override fun setAutofillId(rootId: AutofillId, virtualId: Int) {
+        autofillId = rootId
         this.virtualId = virtualId
     }
 
@@ -144,53 +118,13 @@
     }
 
     override fun setAutofillHints(autofillHints: Array<out String>?) {
-        autofillHints?.let { this.autofillHints = it }
+        autofillHints?.let { this.autofillHints = it.toMutableList() }
     }
 
     override fun setDimens(left: Int, top: Int, x: Int, y: Int, width: Int, height: Int) {
-        this.bounds = Rect(left, top, width - left, height - top)
+        this.bounds = Rect(left, top, left + width, top + height)
     }
 
-    override fun equals(other: Any?) =
-        other is FakeViewStructure &&
-            other.virtualId == virtualId &&
-            other.packageName == packageName &&
-            other.typeName == typeName &&
-            other.entryName == entryName &&
-            other.autofillType == autofillType &&
-            other.autofillHints.contentEquals(autofillHints) &&
-            other.bounds.contentEquals(bounds) &&
-            other.activated == activated &&
-            other.alpha == alpha &&
-            other.autofillOptions.contentEquals(autofillOptions) &&
-            other.autofillValue == autofillValue &&
-            other.className == className &&
-            other.children.count() == children.count() &&
-            other.contentDescription == contentDescription &&
-            other.dataIsSensitive == dataIsSensitive &&
-            other.elevation == elevation &&
-            other.hint == hint &&
-            other.htmlInfo == htmlInfo &&
-            other.inputType == inputType &&
-            other.isEnabled == isEnabled &&
-            other.isCheckable == isCheckable &&
-            other.isChecked == isChecked &&
-            other.isClickable == isClickable &&
-            other.isContextClickable == isContextClickable &&
-            other.isAccessibilityFocused == isAccessibilityFocused &&
-            other.isFocused == isFocused &&
-            other.isLongClickable == isLongClickable &&
-            other.isOpaque == isOpaque &&
-            other.isFocusable == isFocusable &&
-            other.selected == selected &&
-            other.text == text &&
-            other.textLines.contentEquals(textLines) &&
-            other.transformation == transformation &&
-            other.visibility == visibility &&
-            other.webDomain == webDomain
-
-    override fun hashCode() = super.hashCode()
-
     override fun getExtras() = extras
 
     override fun getHint() = hint ?: ""
@@ -199,116 +133,121 @@
 
     override fun hasExtras() = !extras.isEmpty
 
-    override fun setActivated(p0: Boolean) {
-        activated = p0
+    override fun setActivated(state: Boolean) {
+        activated = state
     }
 
-    override fun setAccessibilityFocused(p0: Boolean) {
-        isAccessibilityFocused = p0
+    override fun setAccessibilityFocused(state: Boolean) {
+        isAccessibilityFocused = state
     }
 
-    override fun setAlpha(p0: Float) {
-        alpha = p0
+    override fun setAlpha(alpha: Float) {
+        this.alpha = alpha
     }
 
-    override fun setAutofillOptions(p0: Array<CharSequence>?) {
-        autofillOptions = p0
+    override fun setAutofillOptions(options: Array<CharSequence>?) {
+        autofillOptions = options?.toMutableList()
     }
 
-    override fun setAutofillValue(p0: AutofillValue?) {
-        autofillValue = p0
+    override fun setAutofillValue(value: AutofillValue?) {
+        autofillValue = value
     }
 
-    override fun setCheckable(p0: Boolean) {
-        isCheckable = p0
+    override fun setCheckable(state: Boolean) {
+        isCheckable = state
     }
 
-    override fun setChecked(p0: Boolean) {
-        isChecked = p0
+    override fun setChecked(state: Boolean) {
+        isChecked = state
     }
 
-    override fun setClassName(p0: String?) {
-        className = p0
+    override fun setClassName(className: String?) {
+        this.className = className
     }
 
-    override fun setClickable(p0: Boolean) {
-        isClickable = p0
+    override fun setClickable(state: Boolean) {
+        isClickable = state
     }
 
-    override fun setContentDescription(p0: CharSequence?) {
-        contentDescription = p0
+    override fun setContentDescription(contentDescription: CharSequence?) {
+        this.contentDescription = contentDescription
     }
 
-    override fun setContextClickable(p0: Boolean) {
-        isContextClickable = p0
+    override fun setContextClickable(state: Boolean) {
+        isContextClickable = state
     }
 
-    override fun setDataIsSensitive(p0: Boolean) {
-        dataIsSensitive = p0
+    override fun setDataIsSensitive(sensitive: Boolean) {
+        dataIsSensitive = sensitive
     }
 
-    override fun setElevation(p0: Float) {
-        elevation = p0
+    override fun setElevation(elevation: Float) {
+        this.elevation = elevation
     }
 
-    override fun setEnabled(p0: Boolean) {
-        isEnabled = p0
+    override fun setEnabled(state: Boolean) {
+        isEnabled = state
     }
 
-    override fun setFocusable(p0: Boolean) {
-        isFocusable = p0
+    override fun setFocusable(state: Boolean) {
+        isFocusable = state
     }
 
-    override fun setFocused(p0: Boolean) {
-        isFocused = p0
+    override fun setFocused(state: Boolean) {
+        isFocused = state
     }
 
-    override fun setHtmlInfo(p0: HtmlInfo) {
-        htmlInfo = p0
+    override fun setHtmlInfo(htmlInfo: HtmlInfo) {
+        this.htmlInfo = htmlInfo
     }
 
-    override fun setHint(p0: CharSequence?) {
-        hint = p0
+    override fun setHint(hint: CharSequence?) {
+        this.hint = hint
     }
 
-    override fun setInputType(p0: Int) {
-        inputType = p0
+    override fun setInputType(inputType: Int) {
+        this.inputType = inputType
     }
 
-    override fun setLongClickable(p0: Boolean) {
-        isLongClickable = p0
+    override fun setLongClickable(state: Boolean) {
+        isLongClickable = state
     }
 
-    override fun setOpaque(p0: Boolean) {
-        isOpaque = p0
+    override fun setMaxTextLength(maxLength: Int) {
+        maxTextLength = maxLength
     }
 
-    override fun setSelected(p0: Boolean) {
-        selected = p0
+    override fun setOpaque(opaque: Boolean) {
+        isOpaque = opaque
     }
 
-    override fun setText(p0: CharSequence?) {
-        p0?.let { text = it }
+    override fun setSelected(state: Boolean) {
+        isSelected = state
     }
 
-    override fun setText(p0: CharSequence?, p1: Int, p2: Int) {
-        p0?.let { text = it.subSequence(p1, p2) }
+    override fun setText(charSequence: CharSequence?) {
+        charSequence?.let { text = it }
     }
 
-    override fun setTextLines(p0: IntArray?, p1: IntArray?) {
-        textLines = p0
+    override fun setText(charSequence: CharSequence?, selectionStart: Int, selectionEnd: Int) {
+        charSequence?.let { text = it.subSequence(selectionStart, selectionEnd) }
     }
 
-    override fun setTransformation(p0: Matrix?) {
-        transformation = p0
+    override fun setTextLines(charOffsets: IntArray?, baselines: IntArray?) {
+        textLinesCharOffsets = charOffsets?.toMutableList()
+        textLinesBaselines = baselines?.toMutableList()
     }
 
-    override fun setVisibility(p0: Int) {
-        visibility = p0
+    override fun setTransformation(matrix: Matrix?) {
+        transformation = matrix
     }
 
-    override fun setWebDomain(p0: String?) {
-        webDomain = p0
+    override fun setVisibility(visibility: Int) {
+        this.visibility = visibility
+    }
+
+    override fun setWebDomain(domain: String?) {
+        webDomain = domain
     }
 
     // Unimplemented methods.
@@ -316,7 +255,7 @@
         TODO("not implemented")
     }
 
-    override fun asyncNewChild(p0: Int): ViewStructure {
+    override fun asyncNewChild(index: Int): ViewStructure {
         TODO("not implemented")
     }
 
@@ -328,42 +267,19 @@
         TODO("not implemented")
     }
 
-    override fun newHtmlInfoBuilder(p0: String): HtmlInfo.Builder {
+    override fun newHtmlInfoBuilder(tagName: String): HtmlInfo.Builder {
         TODO("not implemented")
     }
 
-    override fun setAutofillId(p0: AutofillId) {
+    override fun setChildCount(num: Int) {
         TODO("not implemented")
     }
 
-    override fun setChildCount(p0: Int) {
+    override fun setLocaleList(localeList: LocaleList?) {
         TODO("not implemented")
     }
 
-    override fun setLocaleList(p0: LocaleList?) {
+    override fun setTextStyle(size: Float, fgColor: Int, bgColor: Int, style: Int) {
         TODO("not implemented")
     }
-
-    override fun setTextStyle(p0: Float, p1: Int, p2: Int, p3: Int) {
-        TODO("not implemented")
-    }
-}
-
-private fun Rect?.contentEquals(other: Rect?) =
-    when {
-        (other == null && this == null) -> true
-        (other == null || this == null) -> false
-        else ->
-            other.left == left && other.right == right && other.bottom == bottom && other.top == top
-    }
-
-/** Obtains a parcel and then recycles it correctly whether an exception is thrown or not. */
-private fun useParcel(block: (Parcel) -> Unit) {
-    var parcel: Parcel? = null
-    try {
-        parcel = Parcel.obtain()
-        block(parcel)
-    } finally {
-        parcel?.recycle()
-    }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt
index a9adc07..c878248 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/PerformAndroidAutofillManagerTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.ui.autofill
 
+import android.graphics.Rect
 import android.os.Build
 import android.text.InputType
 import android.util.SparseArray
@@ -27,22 +28,19 @@
 import androidx.annotation.RequiresApi
 import androidx.autofill.HintConstants
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.toggleable
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
+import androidx.compose.ui.ComposeUiFlags
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.platform.AndroidComposeView
-import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.Role
@@ -52,25 +50,29 @@
 import androidx.compose.ui.semantics.hideFromAccessibility
 import androidx.compose.ui.semantics.maxTextLength
 import androidx.compose.ui.semantics.onLongClick
+import androidx.compose.ui.semantics.password
+import androidx.compose.ui.semantics.requestFocus
 import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.setText
 import androidx.compose.ui.semantics.toggleableState
 import androidx.compose.ui.state.ToggleableState
 import androidx.compose.ui.test.TestActivity
 import androidx.compose.ui.test.assertTextEquals
 import androidx.compose.ui.test.isEnabled
-import androidx.compose.ui.test.isFocusable
-import androidx.compose.ui.test.isFocused
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.requestFocus
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.Ignore
 import org.junit.After
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -82,14 +84,20 @@
 // data types are supported.
 class PerformAndroidAutofillManagerTest {
     @get:Rule val rule = createAndroidComposeRule<TestActivity>()
-    private lateinit var androidComposeView: AndroidComposeView
-    private lateinit var composeView: View
-
     private val height = 200.dp
     private val width = 200.dp
 
     private val contentTag = "content_tag"
 
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
+    @Before
+    fun enableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = true
+    }
+
     @After
     fun teardown() {
         val instrumentation = InstrumentationRegistry.getInstrumentation()
@@ -101,6 +109,8 @@
                 }
             }
         }
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
     }
 
     // The "filling" user journey consists of populating a viewStructure for the Autofill framework
@@ -116,27 +126,29 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_empty() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent { view = LocalView.current }
 
         // Act.
-        rule.setContentWithAutofillEnabled {
+        rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure.childCount).isEqualTo(0)
+        assertThat(viewStructure.childCount).isEqualTo(0)
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
-    fun populateViewStructure_defaultValues_26() {
+    fun populateViewStructure_defaultValues() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        // Act.
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier
                     // TODO(333102566): for now we need this Autofill contentType to get the
@@ -149,70 +161,34 @@
             )
         }
 
-        rule.runOnIdle {
-            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
-        }
-
-        // Assert.
-        Truth.assertThat(viewStructure)
-            .isEqualTo(
-                FakeViewStructure().apply {
-                    children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.VISIBLE)
-                            setLongClickable(false)
-                            setFocusable(false)
-                            setFocused(false)
-                            setEnabled(false)
-                        }
-                    )
-                }
-            )
-    }
-
-    @Test
-    @SmallTest
-    @SdkSuppress(minSdkVersion = 28)
-    fun populateViewStructure_defaultValues_28() {
-        // Arrange.
-        val viewStructure: ViewStructure = FakeViewStructure()
-
         // Act.
-        rule.setContentWithAutofillEnabled {
-            Box(
-                Modifier
-                    // TODO(333102566): for now we need this Autofill contentType to get the
-                    // ViewStructure populated. Once Autofill is triggered for all semantics nodes
-                    // (not just ones related to Autofill) the semantics below will no longer be
-                    // necessary.
-                    .semantics { contentType = ContentType.Username }
-                    .size(height, width)
-                    .testTag(contentTag)
-            )
-        }
-
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
                 FakeViewStructure().apply {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    packageName = view.context.applicationInfo.packageName
+                    bounds = Rect(0, 0, width.dpToPx(), height.dpToPx())
+                    autofillId = view.autofillId
+                    isEnabled = true
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.VISIBLE)
-                            setLongClickable(false)
-                            setFocusable(false)
-                            setFocused(false)
-                            setEnabled(false)
-                            setMaxTextLength(-1)
+                        FakeViewStructure().apply {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            packageName = view.context.applicationInfo.packageName
+                            bounds = Rect(0, 0, width.dpToPx(), height.dpToPx())
+                            autofillId = view.autofillId
+                            isEnabled = true
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            visibility = View.VISIBLE
+                            isLongClickable = false
+                            isFocusable = false
+                            isFocused = false
+                            isEnabled = true
                         }
                     )
                 }
@@ -224,9 +200,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_contentType() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(height, width)
@@ -234,19 +211,21 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
                         }
                     )
                 }
@@ -258,9 +237,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_contentDataType() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentDataType = ContentDataType.Text }
                     .size(height, width)
@@ -268,19 +248,21 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillType(AUTOFILL_TYPE_TEXT)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillType = AUTOFILL_TYPE_TEXT
                         }
                     )
                 }
@@ -292,9 +274,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_clickable() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(width, height)
@@ -303,21 +286,23 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setClickable(true)
-                            setFocusable(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isClickable = true
+                            isFocusable = true
                         }
                     )
                 }
@@ -329,9 +314,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_contentDescription() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -342,20 +328,22 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setContentDescription(contentTag)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            contentDescription = contentTag
                         }
                     )
                 }
@@ -367,9 +355,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_role_tab() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(width, height)
@@ -385,23 +374,24 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setClickable(true)
-                            setFocusable(true)
-                            setEnabled(true)
-                            setSelected(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isClickable = true
+                            isFocusable = true
+                            isSelected = true
                         }
                     )
                 }
@@ -411,11 +401,12 @@
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
-    fun populateViewStructure_role_button() {
+    fun populateViewStructure_role_radioButton() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(width, height)
@@ -431,23 +422,129 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setClickable(true)
-                            setFocusable(true)
-                            setEnabled(true)
-                            setSelected(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            className = "android.widget.RadioButton"
+                            isClickable = true
+                            isFocusable = true
+                            isCheckable = true
+                            isChecked = true
+                            isSelected = true
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_role_dropdownList() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                Modifier.semantics { contentType = ContentType.Username }
+                    .size(width, height)
+                    .selectable(
+                        selected = true,
+                        onClick = {},
+                        enabled = true,
+                        role = Role.DropdownList,
+                        interactionSource = null,
+                        indication = null
+                    )
+                    .testTag(contentTag)
+            )
+        }
+
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            className = "android.widget.Spinner"
+                            isClickable = true
+                            isFocusable = true
+                            isCheckable = true
+                            isChecked = true
+                            isSelected = true
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_role_valuePicker() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                Modifier.semantics { contentType = ContentType.Username }
+                    .size(width, height)
+                    .selectable(
+                        selected = true,
+                        onClick = {},
+                        enabled = true,
+                        role = Role.ValuePicker,
+                        interactionSource = null,
+                        indication = null
+                    )
+                    .testTag(contentTag)
+            )
+        }
+
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            className = "android.widget.NumberPicker"
+                            isClickable = true
+                            isFocusable = true
+                            isCheckable = true
+                            isChecked = true
+                            isSelected = true
                         }
                     )
                 }
@@ -459,9 +556,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_hideFromAccessibility() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -472,35 +570,39 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert that even if a component is unimportant for accessibility, it can still be
         // accessed by autofill.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.VISIBLE)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            visibility = View.VISIBLE
                         }
                     )
                 }
             )
     }
 
+    @Ignore // TODO(b/383198004): Add support for notifyVisibilityChanged.
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_invisibility() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.alpha(0f)
                     .semantics { contentType = ContentType.Username }
@@ -509,34 +611,38 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.INVISIBLE)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            visibility = View.INVISIBLE
                         }
                     )
                 }
             )
     }
 
+    @Ignore // TODO(b/383198004): Add support for notifyVisibilityChanged.
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_visibility() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .size(width, height)
@@ -544,34 +650,38 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.VISIBLE)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            visibility = View.VISIBLE
                         }
                     )
                 }
             )
     }
 
+    @Ignore // TODO(b/383198004): Add support for notifyVisibilityChanged.
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_invisibility_alpha() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics { contentType = ContentType.Username }
                     .alpha(0f) // this node should now be invisible
@@ -580,20 +690,22 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setVisibility(View.INVISIBLE)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            visibility = View.INVISIBLE
                         }
                     )
                 }
@@ -605,9 +717,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_longClickable() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -618,20 +731,22 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setLongClickable(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isLongClickable = true
                         }
                     )
                 }
@@ -641,37 +756,79 @@
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
-    fun populateViewStructure_focused_focusable() {
+    fun populateViewStructure_focusable() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
-                        isFocusable()
-                        isFocused()
+                        requestFocus { true }
                     }
                     .size(width, height)
                     .testTag(contentTag)
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setFocusable(true)
-                            setFocused(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isFocusable = true
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_focusable_focused() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                Modifier.semantics { contentType = ContentType.Username }
+                    .focusable()
+                    .size(width, height)
+                    .testTag(contentTag)
+            )
+        }
+        rule.onNodeWithTag(contentTag).requestFocus()
+
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isFocusable = true
+                            isFocused = true
                         }
                     )
                 }
@@ -683,9 +840,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_enabled() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -696,20 +854,22 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setEnabled(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            isEnabled = true
                         }
                     )
                 }
@@ -721,9 +881,54 @@
     @SdkSuppress(minSdkVersion = 28)
     fun populateViewStructure_setMaxLength() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                Modifier.semantics {
+                        contentType = ContentType.Username
+                        setText { true }
+                        maxTextLength = 5
+                    }
+                    .size(width, height)
+                    .testTag(contentTag)
+            )
+        }
 
-        rule.setContentWithAutofillEnabled {
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillType = AUTOFILL_TYPE_TEXT
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            maxTextLength = 5
+                            className = "android.widget.EditText"
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 28)
+    fun populateViewStructure_setMaxLength_notSetForNonTextItems() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -734,20 +939,22 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setMaxTextLength(5)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            maxTextLength = -1
                         }
                     )
                 }
@@ -759,9 +966,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_checkable_unchecked() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -772,22 +980,23 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
-            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder.
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setAutofillType(View.AUTOFILL_TYPE_TOGGLE)
-                            setCheckable(true)
-                            setFocusable(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            autofillType = View.AUTOFILL_TYPE_TOGGLE
+                            isCheckable = true
                         }
                     )
                 }
@@ -799,9 +1008,10 @@
     @SdkSuppress(minSdkVersion = 26)
     fun populateViewStructure_checkable_checked() {
         // Arrange.
+        lateinit var view: View
         val viewStructure: ViewStructure = FakeViewStructure()
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Box(
                 Modifier.semantics {
                         contentType = ContentType.Username
@@ -812,23 +1022,24 @@
             )
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setAutofillType(View.AUTOFILL_TYPE_TOGGLE)
-                            setCheckable(true)
-                            setChecked(true)
-                            setFocusable(true)
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            autofillType = View.AUTOFILL_TYPE_TOGGLE
+                            isCheckable = true
+                            isChecked = true
                         }
                     )
                 }
@@ -838,44 +1049,89 @@
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
-    fun populateViewStructure_usernameChild() {
+    fun populateViewStructure_checkable() {
         // Arrange.
-        val viewStructure = FakeViewStructure()
+        lateinit var view: View
+        val viewStructure: ViewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                Modifier.semantics { contentType = ContentType.Username }
+                    .toggleable(true) {}
+                    .size(width, height)
+                    .testTag(contentTag)
+            )
+        }
 
-        rule.setContentWithAutofillEnabled {
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            autofillType = View.AUTOFILL_TYPE_TOGGLE
+                            isCheckable = true
+                            isChecked = true
+                            isFocusable = true
+                            isClickable = true
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_username_empty() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicTextField(
                     state = remember { TextFieldState() },
                     modifier =
                         Modifier.semantics { contentType = ContentType.Username }
+                            .size(height, width)
                             .testTag(contentTag)
                 )
             }
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
                             text = ""
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setAutofillType(AUTOFILL_TYPE_TEXT)
-                            setAutofillValue(AutofillValue.forText(""))
-                            setClassName(
-                                AndroidComposeViewAccessibilityDelegateCompat.TextFieldClassName
-                            )
-                            setClickable(true)
-                            setFocusable(true)
-                            setLongClickable(true)
-                            setVisibility(View.VISIBLE)
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            autofillType = AUTOFILL_TYPE_TEXT
+                            autofillValue = AutofillValue.forText("")
+                            className = "android.widget.EditText"
+                            isClickable = true
+                            isFocusable = true
+                            isLongClickable = true
+                            visibility = View.VISIBLE
                         }
                     )
                 }
@@ -885,11 +1141,61 @@
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
-    fun populateViewStructure_passwordChild() {
+    fun populateViewStructure_username_specified() {
         // Arrange.
+        lateinit var view: View
         val viewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Column {
+                BasicTextField(
+                    state = remember { TextFieldState("testUsername") },
+                    modifier =
+                        Modifier.semantics { contentType = ContentType.Username }
+                            .size(height, width)
+                            .testTag(contentTag)
+                )
+            }
+        }
 
-        rule.setContentWithAutofillEnabled {
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            text = ""
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            autofillType = AUTOFILL_TYPE_TEXT
+                            autofillValue = AutofillValue.forText("testUsername")
+                            className = "android.widget.EditText"
+                            isClickable = true
+                            isFocusable = true
+                            isLongClickable = true
+                            visibility = View.VISIBLE
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_password_empty() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
             val passwordState = remember { TextFieldState() }
 
             Column {
@@ -897,38 +1203,133 @@
                     state = passwordState,
                     modifier =
                         Modifier.semantics { contentType = ContentType.Password }
+                            .size(height, width)
                             .testTag(contentTag)
                 )
             }
         }
 
+        // Act.
         rule.runOnIdle {
             // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
-            androidComposeView.onProvideAutofillVirtualStructure(viewStructure, 0)
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
         }
 
         // Assert.
-        Truth.assertThat(viewStructure)
+        assertThat(viewStructure)
             .isEqualTo(
-                FakeViewStructure().apply {
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
                     children.add(
-                        FakeViewStructure {
-                            virtualId = contentTag.semanticsId()
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
                             text = ""
-                            setAutofillHints(arrayOf(HintConstants.AUTOFILL_HINT_USERNAME))
-                            setAutofillType(AUTOFILL_TYPE_TEXT)
-                            setAutofillValue(AutofillValue.forText(""))
-                            setClassName(
-                                AndroidComposeViewAccessibilityDelegateCompat.TextFieldClassName
-                            )
-                            setClickable(true)
-                            setDataIsSensitive(true)
-                            setInputType(
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_PASSWORD)
+                            autofillType = AUTOFILL_TYPE_TEXT
+                            autofillValue = AutofillValue.forText("")
+                            className = "android.widget.EditText"
+                            isClickable = true
+                            dataIsSensitive = true
+                            inputType =
                                 InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
-                            )
-                            setFocusable(true)
-                            setLongClickable(true)
-                            setVisibility(View.VISIBLE)
+                            isFocusable = true
+                            isLongClickable = true
+                            visibility = View.VISIBLE
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_password_asContentType() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure = FakeViewStructure()
+        rule.setContent {
+            view = LocalView.current
+            Column {
+                BasicTextField(
+                    state = remember { TextFieldState("testPassword") },
+                    modifier =
+                        Modifier.semantics { contentType = ContentType.Password }
+                            .size(height, width)
+                            .testTag(contentTag)
+                )
+            }
+        }
+
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            text = ""
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_PASSWORD)
+                            autofillType = AUTOFILL_TYPE_TEXT
+                            autofillValue = AutofillValue.forText("testPassword")
+                            className = "android.widget.EditText"
+                            isClickable = true
+                            dataIsSensitive = true
+                            inputType =
+                                InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
+                            isFocusable = true
+                            isLongClickable = true
+                            visibility = View.VISIBLE
+                        }
+                    )
+                }
+            )
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = 26)
+    fun populateViewStructure_password_asSemanticProperty() {
+        // Arrange.
+        lateinit var view: View
+        val viewStructure = FakeViewStructure()
+
+        rule.setContent {
+            view = LocalView.current
+            Box(
+                modifier =
+                    Modifier.semantics {
+                            contentType = ContentType.Username
+                            password()
+                        }
+                        .size(height, width)
+                        .testTag(contentTag)
+            )
+        }
+
+        // Act.
+        rule.runOnIdle {
+            // Compose does not use the Autofill flags parameter, passing in 0 as a placeholder flag
+            view.onProvideAutofillVirtualStructure(viewStructure, 0)
+        }
+
+        // Assert.
+        assertThat(viewStructure)
+            .isEqualTo(
+                ViewStructure(view) {
+                    virtualId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
+                    children.add(
+                        ViewStructure(view) {
+                            virtualId = rule.onNodeWithTag(contentTag).semanticsId()
+                            autofillHints = mutableListOf(HintConstants.AUTOFILL_HINT_USERNAME)
+                            dataIsSensitive = true
                         }
                     )
                 }
@@ -943,13 +1344,12 @@
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun performAutofill_credentials_customBTF() {
-        val expectedUsername = "test_username"
-        val expectedPassword = "test_password1111"
-
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
         val passwordTag = "password_tag"
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicTextField(
                     state = remember { TextFieldState() },
@@ -966,40 +1366,43 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-                append(passwordTag.semanticsId(), AutofillValue.forText(expectedPassword))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        val passwordId = rule.onNodeWithTag(passwordTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                    append(passwordId, AutofillValue.forText("testPassword"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername)
-        rule.onNodeWithTag(passwordTag).assertTextEquals(expectedPassword)
+        // Assert,
+        rule.onNodeWithTag(usernameTag).assertTextEquals("testUsername")
+        rule.onNodeWithTag(passwordTag).assertTextEquals("testPassword")
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun performAutofill_payment_customBTF() {
-        val expectedCreditCardNumber = "123 456 789"
-        val expectedSecurityCode = "123"
-
+        // Arrange.
+        lateinit var view: View
         val creditCardTag = "credit_card_tag"
         val securityCodeTag = "security_code_tag"
 
-        rule.setContentWithAutofillEnabled {
-            val creditCardInput = remember { TextFieldState() }
-            val securityCodeInput = remember { TextFieldState() }
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicTextField(
-                    state = creditCardInput,
+                    state = remember { TextFieldState() },
                     modifier =
                         Modifier.semantics { contentType = ContentType.CreditCardNumber }
                             .testTag(creditCardTag)
                 )
                 BasicTextField(
-                    state = securityCodeInput,
+                    state = remember { TextFieldState() },
                     modifier =
                         Modifier.semantics { contentType = ContentType.CreditCardSecurityCode }
                             .testTag(securityCodeTag)
@@ -1007,50 +1410,38 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(creditCardTag.semanticsId(), AutofillValue.forText(expectedCreditCardNumber))
-                append(securityCodeTag.semanticsId(), AutofillValue.forText(expectedSecurityCode))
-            }
+        // Act.
+        val creditCardId = rule.onNodeWithTag(creditCardTag).semanticsId()
+        val securityCodeId = rule.onNodeWithTag(securityCodeTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(creditCardId, AutofillValue.forText("0123 4567 8910 1112"))
+                    append(securityCodeId, AutofillValue.forText("123"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        rule.onNodeWithTag(creditCardTag).assertTextEquals(expectedCreditCardNumber)
-        rule.onNodeWithTag(securityCodeTag).assertTextEquals(expectedSecurityCode)
+        // Assert.
+        rule.onNodeWithTag(creditCardTag).assertTextEquals("0123 4567 8910 1112")
+        rule.onNodeWithTag(securityCodeTag).assertTextEquals("123")
     }
 
     // ============================================================================================
     // Helper functions
     // ============================================================================================
-
-    private fun String.semanticsId() = rule.onNodeWithTag(this).fetchSemanticsNode().id
-
     private fun Dp.dpToPx() = with(rule.density) { [email protected]() }
 
-    private inline fun FakeViewStructure(block: FakeViewStructure.() -> Unit): FakeViewStructure {
-        return FakeViewStructure()
-            .apply {
-                packageName = composeView.context.applicationInfo.packageName
-                setDimens(0, 0, 0, 0, width.dpToPx(), height.dpToPx())
-            }
-            .apply(block)
-    }
-
-    @OptIn(ExperimentalComposeUiApi::class)
-    private fun ComposeContentTestRule.setContentWithAutofillEnabled(
-        content: @Composable () -> Unit
-    ) {
-        setContent {
-            androidComposeView = LocalView.current as AndroidComposeView
-            androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true
-            isSemanticAutofillEnabled = true
-
-            composeView = LocalView.current
-            LaunchedEffect(Unit) {
-                // Make sure the delay between batches of events is set to zero.
-                (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L)
-            }
-            content()
+    private inline fun ViewStructure(
+        view: View,
+        block: FakeViewStructure.() -> Unit
+    ): FakeViewStructure {
+        return FakeViewStructure().apply {
+            packageName = view.context.applicationInfo.packageName
+            bounds = Rect(0, 0, width.dpToPx(), height.dpToPx())
+            autofillId = view.autofillId
+            isEnabled = true
+            block()
         }
     }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt
index d00795c..c53af64 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldStateSemanticAutofillTest.kt
@@ -25,26 +25,23 @@
 import androidx.compose.foundation.text.BasicSecureTextField
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
+import androidx.compose.ui.ComposeUiFlags
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.platform.AndroidComposeView
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.contentType
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.test.TestActivity
 import androidx.compose.ui.test.assertTextEquals
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
+import org.junit.After
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -54,8 +51,21 @@
 @RequiresApi(Build.VERSION_CODES.O)
 class TextFieldStateSemanticAutofillTest {
     @get:Rule val rule = createAndroidComposeRule<TestActivity>()
-    private lateinit var androidComposeView: AndroidComposeView
-    private lateinit var composeView: View
+
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
+    @Before
+    fun enableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = true
+    }
+
+    @After
+    fun disableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
+    }
 
     // ============================================================================================
     // Tests to verify BasicTextField populating and filling.
@@ -64,13 +74,12 @@
     @Test
     @SmallTest
     fun performAutofill_credentials_BTF() {
-        val expectedUsername = "test_username"
-        val expectedPassword = "test_password1111"
-
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
         val passwordTag = "password_tag"
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicTextField(
                     state = remember { TextFieldState() },
@@ -89,25 +98,31 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-                append(passwordTag.semanticsId(), AutofillValue.forText(expectedPassword))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        val passwordId = rule.onNodeWithTag(passwordTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                    append(passwordId, AutofillValue.forText("testPassword"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername)
-        rule.onNodeWithTag(passwordTag).assertTextEquals(expectedPassword)
+        // Assert.
+        rule.onNodeWithTag(usernameTag).assertTextEquals("testUsername")
+        rule.onNodeWithTag(passwordTag).assertTextEquals("testPassword")
     }
 
     @Test
     @SmallTest
     fun performAutofill_credentials_BSTF() {
-        val expectedUsername = "test_username"
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicSecureTextField(
                     state = remember { TextFieldState() },
@@ -119,39 +134,19 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername)
+        // Assert.
+        rule.onNodeWithTag(usernameTag).assertTextEquals("testUsername")
     }
 
     // TODO(mnuzen): Mat3 dependencies are pinned, will add Autofill tests in Material3 module
-
-    // ============================================================================================
-    // Helper functions
-    // ============================================================================================
-
-    private fun String.semanticsId() = rule.onNodeWithTag(this).fetchSemanticsNode().id
-
-    @OptIn(ExperimentalComposeUiApi::class)
-    private fun ComposeContentTestRule.setContentWithAutofillEnabled(
-        content: @Composable () -> Unit
-    ) {
-        setContent {
-            androidComposeView = LocalView.current as AndroidComposeView
-            androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true
-            isSemanticAutofillEnabled = true
-
-            composeView = LocalView.current
-            LaunchedEffect(Unit) {
-                // Make sure the delay between batches of events is set to zero.
-                (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L)
-            }
-            content()
-        }
-    }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
index 75bc772..1b5a6e5 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
@@ -27,20 +27,16 @@
 import androidx.compose.material.OutlinedTextField
 import androidx.compose.material.Text
 import androidx.compose.material.TextField
-import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
 import androidx.compose.testutils.assertContainsColor
 import androidx.compose.testutils.assertDoesNotContainColor
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
+import androidx.compose.ui.ComposeUiFlags
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.platform.AndroidComposeView
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.contentType
@@ -48,13 +44,14 @@
 import androidx.compose.ui.test.TestActivity
 import androidx.compose.ui.test.assertTextEquals
 import androidx.compose.ui.test.captureToImage
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
 import junit.framework.TestCase.assertEquals
+import org.junit.After
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -64,9 +61,21 @@
 @RequiresApi(Build.VERSION_CODES.O)
 class TextFieldsSemanticAutofillTest {
     @get:Rule val rule = createAndroidComposeRule<TestActivity>()
-    private lateinit var androidComposeView: AndroidComposeView
-    private lateinit var composeView: View
-    private var autofillHighlight: Color? = null
+
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
+    @Before
+    fun enableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = true
+    }
+
+    @After
+    fun disableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
+    }
 
     // ============================================================================================
     // Tests to verify legacy TextField populating and filling.
@@ -75,16 +84,15 @@
     @Test
     @SmallTest
     fun performAutofill_credentials_BTF() {
-        val expectedUsername = "test_username"
-        val expectedPassword = "test_password1111"
-
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
         val passwordTag = "password_tag"
-
         var usernameInput by mutableStateOf("")
         var passwordInput by mutableStateOf("")
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 BasicTextField(
                     value = usernameInput,
@@ -105,16 +113,21 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-                append(passwordTag.semanticsId(), AutofillValue.forText(expectedPassword))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        val passwordId = rule.onNodeWithTag(passwordTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                    append(passwordId, AutofillValue.forText("testPassword"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        rule.onNodeWithTag(usernameTag).assertTextEquals(expectedUsername)
-        rule.onNodeWithTag(passwordTag).assertTextEquals(expectedPassword)
+        // Assert
+        rule.onNodeWithTag(usernameTag).assertTextEquals("testUsername")
+        rule.onNodeWithTag(passwordTag).assertTextEquals("testPassword")
     }
 
     // ============================================================================================
@@ -124,11 +137,12 @@
     @Test
     @SmallTest
     fun performAutofill_credentials_legacyTF() {
-        val expectedUsername = "test_username"
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
         var usernameInput by mutableStateOf("")
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 TextField(
                     value = usernameInput,
@@ -142,24 +156,29 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        assertEquals(usernameInput, expectedUsername)
+        // Assert.
+        assertEquals(usernameInput, "testUsername")
     }
 
     @Test
     @SmallTest
     fun performAutofill_credentials_outlinedTF() {
-        val expectedUsername = "test_username"
+        // Arrange.
+        lateinit var view: View
         val usernameTag = "username_tag"
         var usernameInput by mutableStateOf("")
-
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             Column {
                 OutlinedTextField(
                     value = usernameInput,
@@ -173,26 +192,31 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-            }
+        // Act.
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                }
+            )
+        }
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
-
-        assertEquals(usernameInput, expectedUsername)
+        // Assert.
+        assertEquals(usernameInput, "testUsername")
     }
 
     @Test
     @SmallTest
     @SdkSuppress(minSdkVersion = 26)
     fun performAutofill_customHighlight_legacyTF() {
-        val expectedUsername = "test_username"
+        lateinit var view: View
         val usernameTag = "username_tag"
         var usernameInput by mutableStateOf("")
         val customHighlightColor = Color.Red
 
-        rule.setContentWithAutofillEnabled {
+        rule.setContent {
+            view = LocalView.current
             CompositionLocalProvider(LocalAutofillHighlightColor provides customHighlightColor) {
                 Column {
                     TextField(
@@ -208,46 +232,23 @@
             }
         }
 
-        val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(usernameTag.semanticsId(), AutofillValue.forText(expectedUsername))
-            }
-
         // Custom autofill highlight color should not appear prior to autofill being performed
         rule
             .onNodeWithTag(usernameTag)
             .captureToImage()
             .assertDoesNotContainColor(customHighlightColor)
 
-        rule.runOnIdle { androidComposeView.autofill(autofillValues) }
+        val usernameId = rule.onNodeWithTag(usernameTag).semanticsId()
+        rule.runOnIdle {
+            view.autofill(
+                SparseArray<AutofillValue>().apply {
+                    append(usernameId, AutofillValue.forText("testUsername"))
+                }
+            )
+        }
         rule.waitForIdle()
 
         // Custom autofill highlight color should now appear
         rule.onNodeWithTag(usernameTag).captureToImage().assertContainsColor(customHighlightColor)
     }
-
-    // ============================================================================================
-    // Helper functions
-    // ============================================================================================
-
-    private fun String.semanticsId() = rule.onNodeWithTag(this).fetchSemanticsNode().id
-
-    @OptIn(ExperimentalComposeUiApi::class)
-    private fun ComposeContentTestRule.setContentWithAutofillEnabled(
-        content: @Composable () -> Unit
-    ) {
-        setContent {
-            androidComposeView = LocalView.current as AndroidComposeView
-            androidComposeView._autofillManager?.currentSemanticsNodesInvalidated = true
-            isSemanticAutofillEnabled = true
-
-            composeView = LocalView.current
-            autofillHighlight = LocalAutofillHighlightColor.current
-            LaunchedEffect(Unit) {
-                // Make sure the delay between batches of events is set to zero.
-                (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L)
-            }
-            content()
-        }
-    }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt
index baafca3..8d7bed4 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusViewInteropTest.kt
@@ -19,9 +19,7 @@
 import android.content.Context
 import android.graphics.Rect as AndroidRect
 import android.view.View
-import android.widget.Button
 import android.widget.EditText
-import android.widget.LinearLayout
 import androidx.compose.foundation.focusGroup
 import androidx.compose.foundation.focusable
 import androidx.compose.foundation.layout.Box
@@ -35,8 +33,6 @@
 import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.material.Button
-import androidx.compose.material.Text
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
@@ -45,24 +41,14 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusDirection.Companion.Down
-import androidx.compose.ui.focus.FocusDirection.Companion.Left
-import androidx.compose.ui.focus.FocusDirection.Companion.Next
-import androidx.compose.ui.focus.FocusDirection.Companion.Previous
-import androidx.compose.ui.focus.FocusDirection.Companion.Right
-import androidx.compose.ui.focus.FocusDirection.Companion.Up
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalFocusManager
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.LocalViewConfiguration
 import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.assertIsFocused
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performTouchInput
-import androidx.compose.ui.test.requestFocus
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntRect
 import androidx.compose.ui.unit.dp
@@ -225,268 +211,6 @@
         assertThat(thirdFocused).isTrue()
     }
 
-    @Test
-    fun moveFocusThroughUnfocusableComposeViewNext() {
-        lateinit var topEditText: EditText
-        lateinit var composeView: ComposeView
-        lateinit var bottomEditText: EditText
-        lateinit var focusManager: FocusManager
-
-        rule.setContent {
-            focusManager = LocalFocusManager.current
-            AndroidView(
-                modifier = Modifier.fillMaxSize(),
-                factory = { context ->
-                    LinearLayout(context).also { linearLayout ->
-                        linearLayout.orientation = LinearLayout.VERTICAL
-                        EditText(context).also {
-                            linearLayout.addView(it)
-                            topEditText = it
-                        }
-                        ComposeView(context).also {
-                            it.setContent { Box(Modifier.size(10.dp)) }
-                            linearLayout.addView(it)
-                            composeView = it
-                        }
-                        EditText(context).also {
-                            linearLayout.addView(it)
-                            bottomEditText = it
-                        }
-                    }
-                }
-            )
-        }
-
-        rule.runOnIdle { topEditText.requestFocus() }
-
-        rule.runOnIdle { focusManager.moveFocus(Next) }
-
-        rule.runOnIdle {
-            assertThat(topEditText.isFocused).isFalse()
-            assertThat(composeView.isFocused).isFalse()
-            assertThat(bottomEditText.isFocused).isTrue()
-        }
-    }
-
-    @Test
-    fun moveFocusThroughUnfocusableComposeViewDown() {
-        lateinit var topEditText: EditText
-        lateinit var composeView: ComposeView
-        lateinit var bottomEditText: EditText
-        lateinit var focusManager: FocusManager
-
-        rule.setContent {
-            focusManager = LocalFocusManager.current
-            AndroidView(
-                modifier = Modifier.fillMaxSize(),
-                factory = { context ->
-                    LinearLayout(context).also { linearLayout ->
-                        linearLayout.orientation = LinearLayout.VERTICAL
-                        EditText(context).also {
-                            linearLayout.addView(it)
-                            topEditText = it
-                        }
-                        ComposeView(context).also {
-                            it.setContent { Box(Modifier.size(10.dp)) }
-                            linearLayout.addView(it)
-                            composeView = it
-                        }
-                        EditText(context).also {
-                            linearLayout.addView(it)
-                            bottomEditText = it
-                        }
-                    }
-                }
-            )
-        }
-
-        rule.runOnIdle { topEditText.requestFocus() }
-
-        rule.runOnIdle { focusManager.moveFocus(Down) }
-
-        rule.runOnIdle {
-            assertThat(topEditText.isFocused).isFalse()
-            assertThat(composeView.isFocused).isFalse()
-            assertThat(bottomEditText.isFocused).isTrue()
-        }
-    }
-
-    @Test
-    fun focusBetweenComposeViews_NextPrevious() {
-        lateinit var focusManager: FocusManager
-
-        rule.setContent {
-            focusManager = LocalFocusManager.current
-            AndroidView(
-                modifier = Modifier.fillMaxSize(),
-                factory = { context ->
-                    LinearLayout(context).also { linearLayout ->
-                        linearLayout.orientation = LinearLayout.VERTICAL
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button1")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button2")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button3")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                    }
-                }
-            )
-        }
-        rule.onNodeWithTag("button1").requestFocus()
-        rule.onNodeWithTag("button1").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Next) }
-        rule.onNodeWithTag("button2").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Next) }
-        rule.onNodeWithTag("button3").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Previous) }
-        rule.onNodeWithTag("button2").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Previous) }
-        rule.onNodeWithTag("button1").assertIsFocused()
-    }
-
-    @Test
-    fun focusBetweenComposeViews_DownUp() {
-        lateinit var focusManager: FocusManager
-
-        rule.setContent {
-            focusManager = LocalFocusManager.current
-            AndroidView(
-                modifier = Modifier.fillMaxSize(),
-                factory = { context ->
-                    LinearLayout(context).also { linearLayout ->
-                        linearLayout.orientation = LinearLayout.VERTICAL
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button1")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button2")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                        ComposeView(context).also {
-                            it.setContent {
-                                Box(
-                                    Modifier.size(10.dp)
-                                        .focusProperties { canFocus = true }
-                                        .focusable()
-                                        .testTag("button3")
-                                )
-                            }
-                            linearLayout.addView(it)
-                        }
-                    }
-                }
-            )
-        }
-        rule.onNodeWithTag("button1").requestFocus()
-        rule.onNodeWithTag("button1").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Down) }
-        rule.onNodeWithTag("button2").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Down) }
-        rule.onNodeWithTag("button3").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Up) }
-        rule.onNodeWithTag("button2").assertIsFocused()
-        rule.runOnIdle { focusManager.moveFocus(Up) }
-        rule.onNodeWithTag("button1").assertIsFocused()
-    }
-
-    @Test
-    fun requestFocusFromViewMovesToComposeView() {
-        lateinit var androidButton1: Button
-        lateinit var composeView: View
-        val composeButton = FocusRequester()
-        rule.setContent {
-            composeView = LocalView.current
-            Column(Modifier.fillMaxSize()) {
-                Button(
-                    onClick = {},
-                    Modifier.testTag("button")
-                        .focusProperties { canFocus = true }
-                        .focusRequester(composeButton)
-                ) {
-                    Text("Compose Button")
-                }
-                AndroidView(
-                    factory = { context ->
-                        LinearLayout(context).also { linearLayout ->
-                            linearLayout.orientation = LinearLayout.VERTICAL
-                            linearLayout.addView(
-                                Button(context).apply {
-                                    setText("Android Button")
-                                    isFocusableInTouchMode = true
-                                    androidButton1 = this
-                                }
-                            )
-                            linearLayout.addView(
-                                Button(context).apply {
-                                    setText("Android Button 2")
-                                    isFocusableInTouchMode = true
-                                }
-                            )
-                        }
-                    }
-                )
-            }
-        }
-
-        for (direction in arrayOf(Left, Up, Right, Down, Next, Previous)) {
-            rule.runOnIdle { androidButton1.requestFocus() }
-
-            rule.runOnIdle {
-                assertThat(androidButton1.isFocused).isTrue()
-                composeButton.requestFocus(direction)
-            }
-
-            rule.onNodeWithTag("button").assertIsFocused()
-
-            rule.runOnIdle {
-                assertThat(composeView.isFocused).isTrue()
-                assertThat(androidButton1.isFocused).isFalse()
-            }
-        }
-    }
-
     private fun View.getFocusedRect() =
         AndroidRect().run {
             rule.runOnIdle { getFocusedRect(this) }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/OwnerFocusTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/OwnerFocusTest.kt
index 334d095..71f1d7e 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/OwnerFocusTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/OwnerFocusTest.kt
@@ -248,8 +248,7 @@
 
         // Assert.
         rule.runOnIdle {
-            // b/369256395 we must return true when we advertise that we're focusable
-            assertThat(success).isTrue()
+            assertThat(success).isFalse()
             assertThat(ownerView.isFocused).isFalse()
         }
     }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListenerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListenerTest.kt
index 089021b..3ba5d10 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListenerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListenerTest.kt
@@ -42,7 +42,7 @@
 import androidx.compose.ui.node.requireOwner
 import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.spatial.RectInfo
+import androidx.compose.ui.spatial.RelativeLayoutBounds
 import androidx.compose.ui.test.SemanticsMatcher
 import androidx.compose.ui.test.SemanticsNodeInteraction
 import androidx.compose.ui.test.assertAll
@@ -513,8 +513,13 @@
         }
     }
 
-    private fun Modifier.testGlobalLayoutListener(callback: (RectInfo) -> Unit) =
-        this then OnGlobaLayoutListenerElement(throttleMs = 0, debounceMs = 0, callback = callback)
+    private fun Modifier.testGlobalLayoutListener(callback: (RelativeLayoutBounds) -> Unit) =
+        this then
+            OnGlobaLayoutListenerElement(
+                throttleMillis = 0,
+                debounceMillis = 0,
+                callback = callback
+            )
 }
 
 /**
@@ -555,28 +560,28 @@
 }
 
 private data class OnGlobaLayoutListenerElement(
-    val throttleMs: Int,
-    val debounceMs: Int,
-    val callback: (RectInfo) -> Unit,
+    val throttleMillis: Long,
+    val debounceMillis: Long,
+    val callback: (RelativeLayoutBounds) -> Unit,
 ) : ModifierNodeElement<OnGlobalLayoutListenerNode>() {
     override fun create(): OnGlobalLayoutListenerNode =
         OnGlobalLayoutListenerNode(
-            throttleMs = throttleMs,
-            debounceMs = debounceMs,
+            throttleMillis = throttleMillis,
+            debounceMillis = debounceMillis,
             callback = callback
         )
 
     override fun update(node: OnGlobalLayoutListenerNode) {
-        node.throttleMs = throttleMs
-        node.debounceMs = debounceMs
+        node.throttleMillis = throttleMillis
+        node.debounceMillis = debounceMillis
         node.callback = callback
         node.diposeAndRegister()
     }
 
     override fun InspectorInfo.inspectableProperties() {
         name = "onLayoutCalculatorChanged"
-        properties["throttleMs"] = throttleMs
-        properties["debounceMs"] = debounceMs
+        properties["throttleMillis"] = throttleMillis
+        properties["debounceMillis"] = debounceMillis
         properties["callback"] = callback
     }
 
@@ -586,31 +591,31 @@
 
         other as OnGlobaLayoutListenerElement
 
-        if (throttleMs != other.throttleMs) return false
-        if (debounceMs != other.debounceMs) return false
+        if (throttleMillis != other.throttleMillis) return false
+        if (debounceMillis != other.debounceMillis) return false
         if (callback != other.callback) return false
 
         return true
     }
 
     override fun hashCode(): Int {
-        var result = throttleMs
-        result = 31 * result + debounceMs
+        var result = throttleMillis.hashCode()
+        result = 31 * result + debounceMillis.hashCode()
         result = 31 * result + callback.hashCode()
         return result
     }
 }
 
 private class OnGlobalLayoutListenerNode(
-    var throttleMs: Int,
-    var debounceMs: Int,
-    var callback: (RectInfo) -> Unit,
+    var throttleMillis: Long,
+    var debounceMillis: Long,
+    var callback: (RelativeLayoutBounds) -> Unit,
 ) : Modifier.Node() {
     var handle: DisposableHandle? = null
 
     fun diposeAndRegister() {
         handle?.dispose()
-        handle = registerOnGlobalLayoutListener(throttleMs, debounceMs, callback)
+        handle = registerOnGlobalLayoutListener(throttleMillis, debounceMillis, callback)
     }
 
     override fun onAttach() {
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt
index 3ba9202..fd4df19 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/OnGlobalRectChangedTest.kt
@@ -48,7 +48,7 @@
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.spatial.RectInfo
+import androidx.compose.ui.spatial.RelativeLayoutBounds
 import androidx.compose.ui.test.TestActivity
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.unit.IntOffset
@@ -92,14 +92,18 @@
                             minWidth = size,
                             minHeight = size,
                             modifier =
-                                Modifier.onRectChanged(0, 0) { wrap1Position = it.windowRect }
+                                Modifier.onLayoutRectChanged(0, 0) {
+                                    wrap1Position = it.boundsInWindow
+                                }
                         )
                     } else {
                         Wrap(
                             minWidth = size,
                             minHeight = size,
                             modifier =
-                                Modifier.onRectChanged(0, 0) { wrap2Position = it.windowRect }
+                                Modifier.onLayoutRectChanged(0, 0) {
+                                    wrap2Position = it.boundsInWindow
+                                }
                         )
                     }
                 }
@@ -128,7 +132,9 @@
                     minWidth = size,
                     minHeight = size,
                     modifier =
-                        Modifier.onRectChanged(0, 0) { realChildSize = it.rootRect.size.width }
+                        Modifier.onLayoutRectChanged(0, 0) {
+                            realChildSize = it.boundsInRoot.size.width
+                        }
                 )
             }
         }
@@ -159,8 +165,8 @@
                             minWidth = 10,
                             minHeight = 10,
                             modifier =
-                                Modifier.onRectChanged(0, 0) { rect ->
-                                    childGlobalPosition = rect.rootRect.offset()
+                                Modifier.onLayoutRectChanged(0, 0) { rect ->
+                                    childGlobalPosition = rect.boundsInRoot.offset()
                                     latch.countDown()
                                 }
                         )
@@ -192,17 +198,19 @@
                     Wrap(
                         minWidth = 10,
                         minHeight = 10,
-                        modifier = Modifier.onRectChanged(0, 0) { wrap1OnPositionedCalled = true }
+                        modifier =
+                            Modifier.onLayoutRectChanged(0, 0) { wrap1OnPositionedCalled = true }
                     )
                     Wrap(
                         minWidth = 10,
                         minHeight = 10,
-                        modifier = Modifier.onRectChanged(0, 0) { wrap2OnPositionedCalled = true }
+                        modifier =
+                            Modifier.onLayoutRectChanged(0, 0) { wrap2OnPositionedCalled = true }
                     ) {
                         Wrap(
                             minWidth = 10,
                             minHeight = 10,
-                            modifier = Modifier.onRectChanged(0, 0) { latch.countDown() }
+                            modifier = Modifier.onLayoutRectChanged(0, 0) { latch.countDown() }
                         )
                     }
                 }
@@ -220,8 +228,8 @@
         var lambda2Called = false
         var layoutCalled = false
         var placementCalled = false
-        val lambda1: (RectInfo) -> Unit = { lambda1Called = true }
-        val lambda2: (RectInfo) -> Unit = { lambda2Called = true }
+        val lambda1: (RelativeLayoutBounds) -> Unit = { lambda1Called = true }
+        val lambda2: (RelativeLayoutBounds) -> Unit = { lambda2Called = true }
 
         val changeLambda = mutableStateOf(true)
 
@@ -240,7 +248,7 @@
                 modifier =
                     Modifier.then(layoutModifier)
                         .size(10.dp)
-                        .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
+                        .onLayoutRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
             )
         }
 
@@ -268,13 +276,13 @@
     @Test
     fun callbacksAreCalledOnlyOnceWhenLambdaChangesAndLayoutChanges() {
         var lambda1Called = false
-        val lambda1: (RectInfo) -> Unit = {
+        val lambda1: (RelativeLayoutBounds) -> Unit = {
             assert(!lambda1Called)
             lambda1Called = true
         }
 
         var lambda2Called = false
-        val lambda2: (RectInfo) -> Unit = {
+        val lambda2: (RelativeLayoutBounds) -> Unit = {
             assert(!lambda2Called)
             lambda2Called = true
         }
@@ -285,7 +293,7 @@
             Box(
                 modifier =
                     Modifier.size(size.value)
-                        .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
+                        .onLayoutRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
             )
         }
 
@@ -309,13 +317,13 @@
     @Test
     fun callbacksAreCalledOnlyOnceWhenLayoutBelowItAndLambdaChanged() {
         var lambda1Called = false
-        val lambda1: (RectInfo) -> Unit = {
+        val lambda1: (RelativeLayoutBounds) -> Unit = {
             assert(!lambda1Called)
             lambda1Called = true
         }
 
         var lambda2Called = false
-        val lambda2: (RectInfo) -> Unit = {
+        val lambda2: (RelativeLayoutBounds) -> Unit = {
             assert(!lambda2Called)
             lambda2Called = true
         }
@@ -326,7 +334,7 @@
             Box(
                 modifier =
                     Modifier.padding(10.dp)
-                        .onRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
+                        .onLayoutRectChanged(0, 0, if (changeLambda.value) lambda1 else lambda2)
                         .padding(size.value)
                         .size(10.dp)
             )
@@ -364,8 +372,8 @@
                 Layout(
                     {},
                     modifier =
-                        Modifier.onRectChanged(0, 0) {
-                            coordinates = it.windowRect
+                        Modifier.onLayoutRectChanged(0, 0) {
+                            coordinates = it.boundsInWindow
                             positionedLatch.countDown()
                         }
                 ) { _, _ ->
@@ -403,8 +411,8 @@
                 {},
                 modifier =
                     Modifier.graphicsLayer { translationX = offsetX }
-                        .onRectChanged(0, 0) {
-                            coordinates = it.windowRect
+                        .onLayoutRectChanged(0, 0) {
+                            coordinates = it.boundsInWindow
                             positionedLatch.countDown()
                         }
             ) { _, _ ->
@@ -459,8 +467,8 @@
                 Layout(
                     {},
                     modifier =
-                        Modifier.onRectChanged(0, 0) {
-                            coordinates = it.windowRect
+                        Modifier.onLayoutRectChanged(0, 0) {
+                            coordinates = it.boundsInWindow
                             positionedLatch.countDown()
                         }
                 ) { _, constraints ->
@@ -496,15 +504,15 @@
                 DelayedMeasure(50) {
                     Box(Modifier.requiredSize(25.toDp())) {
                         Box(
-                            Modifier.requiredSize(size.toDp()).onRectChanged(0, 0) {
-                                coordinates1 = it.rootRect
+                            Modifier.requiredSize(size.toDp()).onLayoutRectChanged(0, 0) {
+                                coordinates1 = it.boundsInRoot
                             }
                         )
                     }
                     Box(Modifier.requiredSize(25.toDp())) {
                         Box(
-                            Modifier.requiredSize(size.toDp()).onRectChanged(0, 0) {
-                                coordinates2 = it.rootRect
+                            Modifier.requiredSize(size.toDp()).onLayoutRectChanged(0, 0) {
+                                coordinates2 = it.boundsInRoot
                             }
                         )
                     }
@@ -541,12 +549,12 @@
 
             composeView.setContent {
                 Box(
-                    Modifier.fillMaxSize().onRectChanged(0, 0) {
+                    Modifier.fillMaxSize().onLayoutRectChanged(0, 0) {
                         val position = IntArray(2)
                         composeView.getLocationInWindow(position)
                         frameGlobalPosition = IntOffset(position[0], position[1])
 
-                        realGlobalPosition = it.windowRect.offset()
+                        realGlobalPosition = it.boundsInWindow.offset()
 
                         positionedLatch.countDown()
                     }
@@ -572,12 +580,9 @@
             with(LocalDensity.current) {
                 Box {
                     Box(
-                        Modifier.fillMaxSize().padding(start = left.value.toDp()).onRectChanged(
-                            0,
-                            0
-                        ) {
-                            realLeft = it.rootRect.left
-                        }
+                        Modifier.fillMaxSize()
+                            .padding(start = left.value.toDp())
+                            .onLayoutRectChanged(0, 0) { realLeft = it.boundsInRoot.left }
                     )
                 }
             }
@@ -603,8 +608,8 @@
                         Box(Modifier.requiredSize(10.toDp())) {
                             Box(Modifier.requiredSize(10.toDp())) {
                                 Box(
-                                    Modifier.onRectChanged(0, 0) {
-                                            realLeft = it.rootRect.left
+                                    Modifier.onLayoutRectChanged(0, 0) {
+                                            realLeft = it.boundsInRoot.left
                                             positionedLatch.countDown()
                                         }
                                         .requiredSize(10.toDp())
@@ -626,13 +631,16 @@
 
     @Test
     fun testLayerBoundsPositionInRotatedView() {
-        var rect: RectInfo? = null
+        var rect: RelativeLayoutBounds? = null
         var view: View? = null
         var toggle by mutableStateOf(false)
         rule.setContent {
             view = LocalView.current
             if (toggle) {
-                FixedSize(30, Modifier.padding(10).onRectChanged(0, 0) { rect = it }) { /* no-op */
+                FixedSize(
+                    30,
+                    Modifier.padding(10).onLayoutRectChanged(0, 0) { rect = it }
+                ) { /* no-op */
                 }
             }
         }
@@ -648,10 +656,10 @@
 
         rule.runOnIdle {
             val layoutCoordinates = rect!!
-            assertEquals(IntOffset(10, 10), layoutCoordinates.rootRect.offset())
-            assertEquals(IntRect(10, 10, 40, 40), layoutCoordinates.rootRect)
+            assertEquals(IntOffset(10, 10), layoutCoordinates.boundsInRoot.offset())
+            assertEquals(IntRect(10, 10, 40, 40), layoutCoordinates.boundsInRoot)
 
-            val boundsInWindow = layoutCoordinates.windowRect
+            val boundsInWindow = layoutCoordinates.boundsInWindow
             assertEquals(10f * sqrt(2f), boundsInWindow.top.toFloat(), 1f)
             assertEquals(30f * sqrt(2f) / 2f, boundsInWindow.right.toFloat(), 1f)
             assertEquals(-30f * sqrt(2f) / 2f, boundsInWindow.left.toFloat(), 1f)
@@ -668,8 +676,8 @@
                 Popup(alignment = alignment) {
                     FixedSize(
                         30,
-                        Modifier.padding(10).background(Color.Red).onRectChanged(0, 0) {
-                            coords = it.windowRect
+                        Modifier.padding(10).background(Color.Red).onLayoutRectChanged(0, 0) {
+                            coords = it.boundsInWindow
                         }
                     ) { /* no-op */
                     }
@@ -699,11 +707,11 @@
         rule.setContent {
             Box(
                 Modifier.fillMaxSize()
-                    .onRectChanged(0, 0) { coords1 = it.windowRect }
+                    .onLayoutRectChanged(0, 0) { coords1 = it.boundsInWindow }
                     .padding(2.dp)
-                    .onRectChanged(0, 0) { coords2 = it.windowRect }
+                    .onLayoutRectChanged(0, 0) { coords2 = it.boundsInWindow }
                     .padding(3.dp)
-                    .onRectChanged(0, 0) { coords3 = it.windowRect }
+                    .onLayoutRectChanged(0, 0) { coords3 = it.boundsInWindow }
             )
         }
 
@@ -719,18 +727,21 @@
     @Test
     @SmallTest
     fun modifierIsReturningEqualObjectForTheSameLambda() {
-        val lambda: (RectInfo) -> Unit = {}
-        assertEquals(Modifier.onRectChanged(0, 0, lambda), Modifier.onRectChanged(0, 0, lambda))
+        val lambda: (RelativeLayoutBounds) -> Unit = {}
+        assertEquals(
+            Modifier.onLayoutRectChanged(0, 0, lambda),
+            Modifier.onLayoutRectChanged(0, 0, lambda)
+        )
     }
 
     @Test
     @SmallTest
     fun modifierIsReturningNotEqualObjectForDifferentLambdas() {
-        val lambda1: (RectInfo) -> Unit = { print("foo") }
-        val lambda2: (RectInfo) -> Unit = { print("bar") }
+        val lambda1: (RelativeLayoutBounds) -> Unit = { print("foo") }
+        val lambda2: (RelativeLayoutBounds) -> Unit = { print("bar") }
         Assert.assertNotEquals(
-            Modifier.onRectChanged(0, 0, lambda1),
-            Modifier.onRectChanged(0, 0, lambda2)
+            Modifier.onLayoutRectChanged(0, 0, lambda1),
+            Modifier.onLayoutRectChanged(0, 0, lambda2)
         )
     }
 
@@ -748,16 +759,16 @@
                 Box(
                     Modifier.fillMaxSize()
                         .offset { offset }
-                        .onRectChanged(0, 0) {
+                        .onLayoutRectChanged(0, 0) {
                             if (offset != IntOffset.Zero) {
-                                position = it.rootRect.offset()
+                                position = it.boundsInRoot.offset()
                             }
                         }
                 )
                 Box(
                     Modifier.fillMaxSize()
                         .offset { offset }
-                        .onRectChanged(0, 0) {
+                        .onLayoutRectChanged(0, 0) {
                             if (offset != IntOffset.Zero && !hasSent) {
                                 hasSent = true
                                 val now = SystemClock.uptimeMillis()
@@ -784,7 +795,7 @@
                     Box(
                         Modifier.fillMaxSize()
                             .offset { offset }
-                            .onRectChanged(0, 0) { position = it.rootRect.offset() }
+                            .onLayoutRectChanged(0, 0) { position = it.boundsInRoot.offset() }
                     )
                 }
             }
@@ -805,14 +816,14 @@
             val modifier =
                 if (callbackPresent.value) {
                     // Remember lambdas to avoid triggering a node update when the lambda changes
-                    Modifier.onRectChanged(0, 0, remember { { positionCalled1Count++ } })
+                    Modifier.onLayoutRectChanged(0, 0, remember { { positionCalled1Count++ } })
                 } else {
                     Modifier
                 }
             Box(
                 Modifier
                     // Remember lambdas to avoid triggering a node update when the lambda changes
-                    .onRectChanged(0, 0, remember { { positionCalled2Count++ } })
+                    .onLayoutRectChanged(0, 0, remember { { positionCalled2Count++ } })
                     .then(modifier)
                     .fillMaxSize()
             )
@@ -838,7 +849,7 @@
     fun occlusionCalculationOnRectChangedCallbacks() {
         var box2Fraction by mutableStateOf(1f)
 
-        var box0RectInfo: RectInfo? = null
+        var box0Bounds: RelativeLayoutBounds? = null
 
         var box0Occlusions = emptyList<IntRect>()
         var box1Occlusions = emptyList<IntRect>()
@@ -853,8 +864,8 @@
                     Modifier.fillMaxWidth()
                         .fillMaxHeight(0.5f)
                         .align(Alignment.TopStart)
-                        .onRectChanged(0, 0) { rectInfo ->
-                            box0RectInfo = rectInfo
+                        .onLayoutRectChanged(0, 0) { rectInfo ->
+                            box0Bounds = rectInfo
                             box0CallbackCount++
                             box0Occlusions = rectInfo.calculateOcclusions()
                         }
@@ -864,20 +875,19 @@
                     Modifier.fillMaxWidth()
                         .fillMaxHeight(0.7f)
                         .align(Alignment.BottomStart)
-                        .onRectChanged(0, 0) { rectInfo ->
+                        .onLayoutRectChanged(0, 0) { rectInfo ->
                             box1CallbackCount++
                             box1Occlusions = rectInfo.calculateOcclusions()
                         }
                 )
                 Box(
                     // Should initially occlude both boxes
-                    Modifier.fillMaxSize(box2Fraction).align(Alignment.BottomStart).onRectChanged(
-                        0,
-                        0
-                    ) { rectInfo ->
-                        box2CallbackCount++
-                        box2Occlusions = rectInfo.calculateOcclusions()
-                    }
+                    Modifier.fillMaxSize(box2Fraction)
+                        .align(Alignment.BottomStart)
+                        .onLayoutRectChanged(0, 0) { rectInfo ->
+                            box2CallbackCount++
+                            box2Occlusions = rectInfo.calculateOcclusions()
+                        }
                 )
             }
         }
@@ -908,6 +918,6 @@
 
         // Currently, it's possible to capture rectInfo and re-calculate occlusions
         // The new calculation should reflect one less occluding box
-        assertThat(box0RectInfo!!.calculateOcclusions().size).isEqualTo(1)
+        assertThat(box0Bounds!!.calculateOcclusions().size).isEqualTo(1)
     }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/PlacementReusableNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/PlacementReusableNodeTest.kt
new file mode 100644
index 0000000..e144b09
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/PlacementReusableNodeTest.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.layout
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.LayoutAwareModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.OnUnplacedModifierNode
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class PlacementReusableNodeTest {
+
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun onPlacedCalledOnReuseInsideLazyColumn() {
+        lateinit var density: Density
+        val items = 200
+        val visibleItems = 2
+        val itemSize = 50.dp
+        val invocations = arrayOf(0, 0)
+
+        // It's important to share lambda across all iterations
+        val placedCallback0: (LayoutCoordinates) -> Unit = { invocations[0] = invocations[0] + 1 }
+        val placedCallback1: (LayoutCoordinates) -> Unit = { invocations[1] = invocations[1] + 1 }
+        val scrollState = LazyListState()
+        rule.setContent {
+            density = LocalDensity.current
+            LazyColumn(Modifier.size(itemSize, itemSize * visibleItems), scrollState) {
+                items(items) {
+                    Box(Modifier.size(itemSize).onPlaced(placedCallback0)) {
+                        Box(Modifier.size(itemSize).onPlaced(placedCallback1))
+                    }
+                }
+            }
+        }
+
+        var expectedInvocations = visibleItems
+        val delta = with(density) { (itemSize * visibleItems).toPx() }
+        repeat(items / visibleItems) {
+            rule.runOnIdle {
+                assertThat(invocations[0]).isAtLeast(expectedInvocations)
+                assertThat(invocations[1]).isAtLeast(expectedInvocations)
+
+                scrollState.dispatchRawDelta(delta)
+                expectedInvocations += visibleItems
+            }
+        }
+    }
+
+    @Test
+    fun placeMultiplatformInteropView() {
+        val showPlatformInterop = mutableStateOf(true)
+        var currentlyVisible = false
+        rule.setContent {
+            if (showPlatformInterop.value) {
+                TestMultiplatformInteropView(
+                    onAddedToPlatformHierarchy = { currentlyVisible = true },
+                    onRemovedFromPlatformHierarchy = { currentlyVisible = false },
+                    modifier = Modifier.size(100.dp)
+                )
+            }
+        }
+
+        rule.runOnIdle { assertThat(currentlyVisible).isTrue() }
+
+        showPlatformInterop.value = false
+        rule.runOnIdle { assertThat(currentlyVisible).isFalse() }
+    }
+
+    @Test
+    fun placeMultiplatformInteropViewInsideLazyColumn() {
+        lateinit var density: Density
+
+        val currentlyVisible = mutableSetOf<Int>()
+        val scrollState = LazyListState()
+        rule.setContent {
+            density = LocalDensity.current
+            LazyColumn(Modifier.size(100.dp, 250.dp), scrollState) {
+                items(100) { index ->
+                    TestMultiplatformInteropView(
+                        onAddedToPlatformHierarchy = { currentlyVisible += index },
+                        onRemovedFromPlatformHierarchy = { currentlyVisible -= index },
+                        modifier = Modifier.size(100.dp)
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle { assertThat(currentlyVisible).containsExactly(0, 1, 2) }
+
+        rule.runOnIdle { scrollState.dispatchRawDelta(with(density) { 1010.dp.toPx() }) }
+        rule.runOnIdle { assertThat(currentlyVisible).containsExactly(10, 11, 12) }
+
+        rule.runOnIdle { scrollState.dispatchRawDelta(with(density) { -1010.dp.toPx() }) }
+        rule.runOnIdle { assertThat(currentlyVisible).containsExactly(0, 1, 2) }
+    }
+}
+
+/**
+ * This emulates multiplatform interop element that placed and drawn outside of Compose.
+ * onPlaced/onUnplaced callbacks are used to control their lifecycle in platform's views hierarchy.
+ */
+@Composable
+private fun TestMultiplatformInteropView(
+    onAddedToPlatformHierarchy: () -> Unit,
+    onRemovedFromPlatformHierarchy: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier then
+            TrackInteropPlacementModifierElement(
+                onAddedToPlatformHierarchy = onAddedToPlatformHierarchy,
+                onRemovedFromPlatformHierarchy = onRemovedFromPlatformHierarchy
+            )
+    )
+}
+
+private data class TrackInteropPlacementModifierElement(
+    var onAddedToPlatformHierarchy: () -> Unit,
+    var onRemovedFromPlatformHierarchy: () -> Unit,
+) : ModifierNodeElement<TrackInteropPlacementModifierNode>() {
+    override fun create() =
+        TrackInteropPlacementModifierNode(
+            onAddedToPlatformHierarchy = onAddedToPlatformHierarchy,
+            onRemovedFromPlatformHierarchy = onRemovedFromPlatformHierarchy
+        )
+
+    override fun update(node: TrackInteropPlacementModifierNode) {
+        node.onAddedToPlatformHierarchy = onAddedToPlatformHierarchy
+        node.onRemovedFromPlatformHierarchy = onRemovedFromPlatformHierarchy
+    }
+}
+
+private class TrackInteropPlacementModifierNode(
+    var onAddedToPlatformHierarchy: () -> Unit,
+    var onRemovedFromPlatformHierarchy: () -> Unit,
+) : Modifier.Node(), LayoutAwareModifierNode, OnUnplacedModifierNode {
+    private var isPlaced = false
+
+    override fun onPlaced(coordinates: LayoutCoordinates) {
+        onAddedToPlatformHierarchy()
+        isPlaced = true
+    }
+
+    override fun onUnplaced() {
+        onRemovedFromPlatformHierarchy()
+        isPlaced = false
+    }
+
+    override fun onDetach() {
+        // TODO(b/309776096): Remove workaround for missing [onUnplaced]
+        //  once it will be reliable implemented
+        if (isPlaced) {
+            onUnplaced()
+        }
+        super.onDetach()
+    }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/MergedSemanticsConfigurationTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/MergedSemanticsConfigurationTest.kt
new file mode 100644
index 0000000..6d87877
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/MergedSemanticsConfigurationTest.kt
@@ -0,0 +1,389 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.semantics
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.RootForTest
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.text.AnnotatedString
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.collections.listOf
+import kotlin.test.Test
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MergedSemanticsConfigurationTest {
+
+    @get:Rule val rule = createComposeRule()
+
+    lateinit var semanticsOwner: SemanticsOwner
+
+    @Test
+    fun singleModifier() {
+        // Arrange.
+        rule.setTestContent {
+            Box(
+                Modifier.semantics {
+                    testTag = "box"
+                    text = AnnotatedString("1")
+                }
+            )
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("box").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged).isEqualTo(unmerged)
+    }
+
+    @Test
+    fun multipleModifierOnSameNode() {
+        // Arrange.
+        rule.setTestContent {
+            Box(
+                Modifier.semantics {
+                        testTag = "box"
+                        text = AnnotatedString("1")
+                    }
+                    .semantics { text = AnnotatedString("2") }
+            )
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("box").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged).isEqualTo(unmerged)
+    }
+
+    @Test
+    fun multipleModifierOnSameNode_mergingDescendants() {
+        // Arrange.
+        rule.setTestContent {
+            Box(
+                Modifier.semantics(mergeDescendants = true) {
+                        testTag = "box"
+                        text = AnnotatedString("1")
+                    }
+                    .semantics { text = AnnotatedString("2") }
+            )
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("box").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged).isEqualTo(unmerged)
+    }
+
+    @Test
+    fun multipleLayoutNodes_default() {
+        // Arrange.
+        rule.setTestContent {
+            Box(
+                Modifier.semantics {
+                    testTag = "box"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("box").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged).isEqualTo(unmerged)
+    }
+
+    @Test
+    fun multipleLayoutNodes_isMergingDescendants_oneChild() {
+        // Arrange.
+        rule.setTestContent {
+            Box(
+                Modifier.semantics(mergeDescendants = true) {
+                    testTag = "box"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("box").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1"), AnnotatedString("2")))
+    }
+
+    @Test
+    fun multipleLayoutNodes_isMergingDescendants_twoChildren() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.semantics(mergeDescendants = true) {
+                    testTag = "row"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Box(Modifier.semantics { text = AnnotatedString("2.2") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1"), AnnotatedString("2.2"), AnnotatedString("2.1")))
+    }
+
+    @Test
+    fun multipleLayoutNodes_isMergingDescendants_clearAndSetSemantics_twoChildren() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.semantics(mergeDescendants = true) {
+                        testTag = "row"
+                        text = AnnotatedString("1")
+                    }
+                    .clearAndSetSemantics {}
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Box(Modifier.semantics { text = AnnotatedString("2.2") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+    }
+
+    @Test
+    fun multipleLayoutNodes_isClearingSemantics_twoChildren() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.clearAndSetSemantics {
+                    testTag = "row"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Box(Modifier.semantics { text = AnnotatedString("2.2") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+    }
+
+    @Test
+    fun deepHierarchy() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.semantics(mergeDescendants = true) {
+                    testTag = "row"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Column(Modifier.semantics { text = AnnotatedString("2.2") }) {
+                    Box(Modifier.semantics { text = AnnotatedString("3.1") })
+                    Box(Modifier.semantics { text = AnnotatedString("3.2") })
+                }
+                Box(Modifier.semantics { text = AnnotatedString("2.3") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(
+                listOf(
+                    AnnotatedString("1"),
+                    AnnotatedString("2.3"),
+                    AnnotatedString("2.2"),
+                    AnnotatedString("3.2"),
+                    AnnotatedString("3.1"),
+                    AnnotatedString("2.1")
+                )
+            )
+    }
+
+    @Test
+    fun doesNotMergeMergedItems() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.semantics(mergeDescendants = true) {
+                    testTag = "row"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Column(
+                    Modifier.semantics(mergeDescendants = true) { text = AnnotatedString("2.2") }
+                ) {
+                    Box(Modifier.semantics { text = AnnotatedString("3.1") })
+                    Box(Modifier.semantics { text = AnnotatedString("3.2") })
+                }
+                Box(Modifier.semantics { text = AnnotatedString("2.3") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1"), AnnotatedString("2.3"), AnnotatedString("2.1")))
+    }
+
+    @Test
+    fun doesNotIncludeChildrenThatAreCleared() {
+        // Arrange.
+        rule.setTestContent {
+            Row(
+                Modifier.semantics(mergeDescendants = true) {
+                    testTag = "row"
+                    text = AnnotatedString("1")
+                }
+            ) {
+                Box(Modifier.semantics { text = AnnotatedString("2.1") })
+                Column(Modifier.clearAndSetSemantics { text = AnnotatedString("2.2") }) {
+                    Box(Modifier.semantics { text = AnnotatedString("3.1") })
+                    Box(Modifier.semantics { text = AnnotatedString("3.2") })
+                }
+                Box(Modifier.semantics { text = AnnotatedString("2.3") })
+            }
+        }
+
+        // Act.
+        val id = rule.onNodeWithTag("row").semanticsId()
+        val (unmerged, merged) =
+            with(checkNotNull(semanticsOwner[id])) {
+                Pair(semanticsConfiguration, mergedSemanticsConfiguration())
+            }
+
+        // Assert.
+        assertThat(unmerged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(listOf(AnnotatedString("1")))
+        assertThat(merged?.getOrNull(SemanticsProperties.Text))
+            .isEqualTo(
+                listOf(
+                    AnnotatedString("1"),
+                    AnnotatedString("2.3"),
+                    AnnotatedString("2.2"),
+                    AnnotatedString("2.1")
+                )
+            )
+    }
+
+    private fun ComposeContentTestRule.setTestContent(composable: @Composable () -> Unit) {
+        setContent {
+            semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
+            composable()
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
index dcabb22..8dcd4e2 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
@@ -25,6 +25,7 @@
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.node.RootForTest
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
@@ -61,12 +62,13 @@
         // Assert.
         assertThat(rootSemantics).isNotNull()
         assertThat(rootSemantics.parentInfo).isNull()
-        assertThat(rootSemantics.childrenInfo.size).isEqualTo(1)
+        assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration })
+            .comparingElementsUsing(SemanticsConfigurationComparator)
+            .containsExactly(null)
 
         // Assert extension Functions.
         assertThat(rootSemantics.nearestParentThatHasSemantics()).isNull()
         assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren()).isEmpty()
     }
 
     @Test
@@ -81,21 +83,21 @@
 
         // Assert.
         assertThat(rootSemantics.parentInfo).isNull()
-        assertThat(rootSemantics.childrenInfo.asMutableList()).containsExactly(semantics)
+        assertThat(rootSemantics.childrenInfo).containsExactly(semantics)
 
         assertThat(semantics.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics.childrenInfo.size).isEqualTo(0)
+        assertThat(semantics.childrenInfo).isEmpty()
 
         // Assert extension Functions.
         assertThat(rootSemantics.nearestParentThatHasSemantics()).isNull()
         assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
+        assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration })
             .comparingElementsUsing(SemanticsConfigurationComparator)
             .containsExactly(SemanticsConfiguration().apply { testTag = "testTag" })
 
         assertThat(semantics.nearestParentThatHasSemantics()).isEqualTo(rootSemantics)
         assertThat(semantics.findMergingSemanticsParent()).isNull()
-        assertThat(semantics.findSemanticsChildren()).isEmpty()
+        assertThat(semantics.childrenInfo).isEmpty()
     }
 
     @Test
@@ -122,7 +124,7 @@
             )
             .inOrder()
 
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
+        assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration })
             .comparingElementsUsing(SemanticsConfigurationComparator)
             .containsExactly(
                 SemanticsConfiguration().apply { testTag = "item1" },
@@ -132,16 +134,16 @@
 
         checkNotNull(semantics1)
         assertThat(semantics1.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics1.childrenInfo.size).isEqualTo(0)
+        assertThat(semantics1.childrenInfo).isEmpty()
 
         checkNotNull(semantics2)
         assertThat(semantics2.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics2.childrenInfo.size).isEqualTo(0)
+        assertThat(semantics2.childrenInfo).isEmpty()
 
         // Assert extension Functions.
         assertThat(rootSemantics.nearestParentThatHasSemantics()).isNull()
         assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
+        assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration })
             .comparingElementsUsing(SemanticsConfigurationComparator)
             .containsExactly(
                 SemanticsConfiguration().apply { testTag = "item1" },
@@ -151,11 +153,11 @@
 
         assertThat(semantics1.nearestParentThatHasSemantics()).isEqualTo(rootSemantics)
         assertThat(semantics1.findMergingSemanticsParent()).isNull()
-        assertThat(semantics1.findSemanticsChildren()).isEmpty()
+        assertThat(semantics1.childrenInfo).isEmpty()
 
         assertThat(semantics2.nearestParentThatHasSemantics()).isEqualTo(rootSemantics)
         assertThat(semantics2.findMergingSemanticsParent()).isNull()
-        assertThat(semantics2.findSemanticsChildren()).isEmpty()
+        assertThat(semantics2.childrenInfo).isEmpty()
     }
 
     // TODO(ralu): Split this into multiple tests.
@@ -203,13 +205,14 @@
         assertThat(testTarget.parentInfo).isNotEqualTo(row)
         assertThat(testTarget.nearestParentThatHasSemantics()).isEqualTo(row)
         assertThat(testTarget.findMergingSemanticsParent()).isEqualTo(column)
-        assertThat(testTarget.childrenInfo.size).isEqualTo(5)
-        assertThat(testTarget.findSemanticsChildren().map { it.semanticsConfiguration })
+        assertThat(testTarget.childrenInfo.map { it.semanticsConfiguration })
             .comparingElementsUsing(SemanticsConfigurationComparator)
             .containsExactly(
-                SemanticsConfiguration().apply { testTag = "child1" },
+                null,
                 SemanticsConfiguration().apply { testTag = "child2" },
-                SemanticsConfiguration().apply { testTag = "child3" }
+                null,
+                null,
+                null
             )
             .inOrder()
         assertThat(testTarget.semanticsConfiguration?.getOrNull(TestTag)).isEqualTo("testTarget")
@@ -241,6 +244,66 @@
         }
     }
 
+    @Test
+    fun transparent() {
+        // Arrange.
+        rule.setTestContent { Box(Modifier.alpha(0.0f)) { Box(Modifier.testTag("item")) } }
+        rule.waitForIdle()
+
+        // Act.
+        val semantics = rule.getSemanticsInfoForTag("item")
+
+        // Assert.
+        assertThat(semantics?.isTransparent()).isTrue()
+    }
+
+    @Test
+    fun semiTransparent() {
+        // Arrange.
+        rule.setTestContent { Box(Modifier.alpha(0.5f)) { Box(Modifier.testTag("item")) } }
+        rule.waitForIdle()
+
+        // Act.
+        val semantics = rule.getSemanticsInfoForTag("item")
+
+        // Assert.
+        assertThat(semantics?.isTransparent()).isFalse()
+    }
+
+    @Test
+    fun nonTransparent() {
+        // Arrange.
+        rule.setTestContent { Box(Modifier.alpha(1.0f)) { Box(Modifier.testTag("item")) } }
+        rule.waitForIdle()
+
+        // Act.
+        val semantics = rule.getSemanticsInfoForTag("item")
+
+        // Assert.
+        assertThat(semantics?.isTransparent()).isFalse()
+    }
+
+    @Test
+    fun transparencyOfStackedItems() {
+        // Arrange.
+        rule.setTestContent {
+            Box(Modifier.alpha(1.0f)) { Box(Modifier.testTag("item1")) }
+            Box(Modifier.alpha(1.0f)) { Box(Modifier.testTag("item2")) }
+            Box(Modifier.alpha(0.0f)) { Box(Modifier.testTag("item3")) }
+        }
+        rule.waitForIdle()
+
+        // Act.
+        val semantics1 = rule.getSemanticsInfoForTag("item1")
+        val semantics2 = rule.getSemanticsInfoForTag("item2")
+        val semantics3 = rule.getSemanticsInfoForTag("item3")
+
+        // Assert.
+        assertThat(semantics1?.isTransparent()).isFalse()
+        assertThat(semantics2?.isTransparent()).isFalse()
+        assertThat(semantics3?.isTransparent()).isTrue()
+    }
+
     private fun ComposeContentTestRule.setTestContent(composable: @Composable () -> Unit) {
         setContent {
             semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
@@ -248,13 +311,6 @@
         }
     }
 
-    /** Helper function that returns a list of children that is easier to assert on in tests. */
-    private fun SemanticsInfo.findSemanticsChildren(): List<SemanticsInfo> {
-        val children = mutableListOf<SemanticsInfo>()
-        [email protected] { children.add(it) }
-        return children
-    }
-
     private fun ComposeContentTestRule.getSemanticsInfoForTag(
         tag: String,
         useUnmergedTree: Boolean = true
@@ -266,9 +322,10 @@
         private val SemanticsConfigurationComparator =
             Correspondence.from<SemanticsConfiguration, SemanticsConfiguration>(
                 { actual, expected ->
-                    actual != null &&
-                        expected != null &&
-                        actual.getOrNull(TestTag) == expected.getOrNull(TestTag)
+                    (actual == null && expected == null) ||
+                        (actual != null &&
+                            expected != null &&
+                            actual.getOrNull(TestTag) == expected.getOrNull(TestTag))
                 },
                 "has same test tag as "
             )
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
index 77965fe..4311904 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
@@ -53,6 +53,7 @@
 import androidx.test.filters.MediumTest
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.Test
+import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.runner.RunWith
@@ -72,12 +73,21 @@
         fun initParameters() = listOf(false, true)
     }
 
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
     @Before
-    fun setup() {
+    fun enableAutofill() {
         @OptIn(ExperimentalComposeUiApi::class)
         ComposeUiFlags.isSemanticAutofillEnabled = isSemanticAutofillEnabled
     }
 
+    @After
+    fun disableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
+    }
+
     // Initial layout does not trigger listeners. Users have to detect the initial semantics
     //  values by detecting first layout (You can get the bounds from RectManager.RectList).
     @Test
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
index d2cbd28..7cfa2d4c 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
@@ -35,6 +35,7 @@
 import androidx.compose.ui.unit.dp
 import androidx.test.filters.SmallTest
 import com.google.common.truth.Truth.assertThat
+import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -52,12 +53,21 @@
         fun initParameters() = listOf(false, true)
     }
 
+    @OptIn(ExperimentalComposeUiApi::class)
+    private val previousFlagValue = ComposeUiFlags.isSemanticAutofillEnabled
+
     @Before
-    fun setup() {
+    fun enableAutofill() {
         @OptIn(ExperimentalComposeUiApi::class)
         ComposeUiFlags.isSemanticAutofillEnabled = precomputedSemantics
     }
 
+    @After
+    fun disableAutofill() {
+        @OptIn(ExperimentalComposeUiApi::class)
+        ComposeUiFlags.isSemanticAutofillEnabled = previousFlagValue
+    }
+
     @Test
     fun applySemantics_firstComposition() {
         // Arrange.
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/FocusSearchLeftInteropTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/FocusSearchLeftInteropTest.kt
index aa67089..dacdc31 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/FocusSearchLeftInteropTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/viewinterop/FocusSearchLeftInteropTest.kt
@@ -456,6 +456,7 @@
                 AndroidView({ FocusableView(it).apply { view = this } })
             }
         }
+        rule.waitForIdle()
 
         // Act.
         rule.focusSearchLeft()
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofill.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofill.android.kt
index 0b5f51d..1a3e98e 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofill.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofill.android.kt
@@ -21,9 +21,12 @@
 import android.util.SparseArray
 import android.view.View
 import android.view.ViewStructure
+import android.view.autofill.AutofillId
 import android.view.autofill.AutofillManager
 import android.view.autofill.AutofillValue
 import androidx.annotation.RequiresApi
+import androidx.compose.ui.internal.checkPreconditionNotNull
+import androidx.compose.ui.platform.coreshims.ViewCompatShims
 import androidx.compose.ui.util.fastMap
 import androidx.compose.ui.util.fastRoundToInt
 
@@ -39,9 +42,12 @@
     val autofillManager =
         view.context.getSystemService(AutofillManager::class.java)
             ?: error("Autofill service could not be located.")
+    var rootAutofillId: AutofillId
 
     init {
         view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_YES
+        rootAutofillId =
+            checkPreconditionNotNull(ViewCompatShims.getAutofillId(view)?.toAutofillId())
     }
 
     override fun requestAutofillForNode(autofillNode: AutofillNode) {
@@ -82,8 +88,8 @@
     var index = AutofillApi26Helper.addChildCount(root, autofillTree.children.count())
 
     for ((id, autofillNode) in autofillTree.children) {
-        AutofillApi26Helper.newChild(root, index)?.also { child ->
-            AutofillApi26Helper.setAutofillId(child, AutofillApi26Helper.getAutofillId(root)!!, id)
+        AutofillApi26Helper.newChild(root, index).also { child ->
+            AutofillApi26Helper.setAutofillId(child, rootAutofillId, id)
             AutofillApi26Helper.setId(child, id, view.context.packageName, null, null)
             AutofillApi26Helper.setAutofillType(child, ContentDataType.Text.dataType)
             AutofillApi26Helper.setAutofillHints(
@@ -93,7 +99,7 @@
 
             val boundingBox = autofillNode.boundingBox
             if (boundingBox == null) {
-                // Do we need an exception here? warning? silently ignore? If the boundingbox is
+                // Do we need an exception here? warning? silently ignore? If the bounding box is
                 // null, the autofill overlay will not be shown.
                 Log.w(
                     "Autofill Warning",
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
index 4f63ead..371a90a 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
@@ -18,509 +18,247 @@
 
 import android.graphics.Rect
 import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import android.text.InputType
 import android.util.Log
 import android.util.SparseArray
 import android.view.View
 import android.view.ViewStructure
-import android.view.autofill.AutofillManager as PlatformAndroidManager
+import android.view.autofill.AutofillId
 import android.view.autofill.AutofillValue
-import android.view.inputmethod.EditorInfo
 import androidx.annotation.RequiresApi
-import androidx.collection.IntObjectMap
-import androidx.collection.MutableIntObjectMap
-import androidx.collection.intObjectMapOf
-import androidx.collection.mutableIntObjectMapOf
+import androidx.collection.MutableIntSet
+import androidx.collection.mutableObjectListOf
 import androidx.compose.ui.internal.checkPreconditionNotNull
-import androidx.compose.ui.platform.AndroidComposeView
-import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextClassName
-import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName
-import androidx.compose.ui.platform.SemanticsNodeCopy
-import androidx.compose.ui.platform.SemanticsNodeWithAdjustedBounds
-import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToIntObjectMap
-import androidx.compose.ui.semantics.Role.Companion.Tab
-import androidx.compose.ui.semantics.SemanticsActions.OnAutofillText
-import androidx.compose.ui.semantics.SemanticsActions.OnClick
-import androidx.compose.ui.semantics.SemanticsActions.OnLongClick
-import androidx.compose.ui.semantics.SemanticsActions.SetText
-import androidx.compose.ui.semantics.SemanticsNode
-import androidx.compose.ui.semantics.SemanticsProperties.ContentDataType as SemanticsContentDataType
-import androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
-import androidx.compose.ui.semantics.SemanticsProperties.ContentType
-import androidx.compose.ui.semantics.SemanticsProperties.Disabled
-import androidx.compose.ui.semantics.SemanticsProperties.EditableText
-import androidx.compose.ui.semantics.SemanticsProperties.Focused
-import androidx.compose.ui.semantics.SemanticsProperties.MaxTextLength
-import androidx.compose.ui.semantics.SemanticsProperties.Password
-import androidx.compose.ui.semantics.SemanticsProperties.Role
-import androidx.compose.ui.semantics.SemanticsProperties.Selected
-import androidx.compose.ui.semantics.SemanticsProperties.Text
-import androidx.compose.ui.semantics.SemanticsProperties.ToggleableState
+import androidx.compose.ui.platform.coreshims.ViewCompatShims
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsConfiguration
+import androidx.compose.ui.semantics.SemanticsInfo
+import androidx.compose.ui.semantics.SemanticsListener
+import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.semantics.getOrNull
-import androidx.compose.ui.state.ToggleableState.On
+import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.util.fastForEach
 
+private const val logTag = "ComposeAutofillManager"
+
 /**
  * Semantic autofill implementation for Android.
  *
  * @param view The parent compose view.
  */
 @RequiresApi(Build.VERSION_CODES.O)
-internal class AndroidAutofillManager(val view: AndroidComposeView) :
-    AutofillManager, View.OnAttachStateChangeListener {
-    internal var autofillManager: AutofillManagerWrapper = AutofillManagerWrapperImpl(view)
+internal class AndroidAutofillManager(
+    var platformAutofillManager: PlatformAutofillManager,
+    private val semanticsOwner: SemanticsOwner,
+    private val view: View,
+    private val rectManager: RectManager,
+    private val packageName: String,
+) : AutofillManager, SemanticsListener {
+    private var reusableRect = Rect()
+    private var rootAutofillId: AutofillId
 
     init {
         view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_YES
-    }
-
-    private val handler = Handler(Looper.getMainLooper())
-
-    // `previousSemanticsNodes` holds the previous pruned semantics tree so that we can compare the
-    // current and previous trees in onSemanticsChange(). We use SemanticsNodeCopy here because
-    // SemanticsNode's children are dynamically generated and always reflect the current children.
-    // We need to keep a copy of its old structure for comparison.
-    private var previousSemanticsNodes: MutableIntObjectMap<SemanticsNodeCopy> =
-        mutableIntObjectMapOf()
-    private var previousSemanticsRoot =
-        SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, intObjectMapOf())
-    private var checkingForSemanticsChanges = false
-
-    internal var currentSemanticsNodesInvalidated = true
-    // This will be used to request autofill when `AutofillManager.requestAutofill()` is called
-    // (e.g. from the text toolbar).
-    private var previouslyFocusedId = 0
-
-    internal var currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds> =
-        intObjectMapOf()
-        get() {
-            if (currentSemanticsNodesInvalidated) { // first instance of retrieving all nodes
-                currentSemanticsNodesInvalidated = false
-                field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap()
-            }
-            return field
-        }
-
-    private fun updateSemanticsCopy() {
-        previousSemanticsNodes.clear()
-        currentSemanticsNodes.forEach { key, value ->
-            previousSemanticsNodes[key] =
-                SemanticsNodeCopy(value.semanticsNode, currentSemanticsNodes)
-        }
-        previousSemanticsRoot =
-            SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, currentSemanticsNodes)
-    }
-
-    private val autofillChangeChecker = Runnable {
-        checkForAutofillPropertyChanges(currentSemanticsNodes)
-        updateSemanticsCopy()
-        checkingForSemanticsChanges = false
-    }
-
-    private fun checkForAutofillPropertyChanges(
-        newSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>
-    ) {
-        newSemanticsNodes.forEachKey { id ->
-            // We do this search because the new configuration is set as a whole, so we
-            // can't indicate which property is changed when setting the new configuration.
-            val previousNode = previousSemanticsNodes[id]
-            val currNode =
-                checkPreconditionNotNull(newSemanticsNodes[id]?.semanticsNode) {
-                    "no value for specified key"
-                }
-
-            if (previousNode == null) {
-                return@forEachKey
-            }
-
-            // Notify Autofill that the value has changed if there is a difference between
-            // the previous and current values.
-
-            // Check Editable Text —————————
-            val previousText = previousNode.unmergedConfig.getOrNull(EditableText)?.text
-            val newText = currNode.unmergedConfig.getOrNull(EditableText)?.text
-            if (previousText != newText && newText != null) {
-                notifyAutofillValueChanged(id, newText)
-            }
-
-            // Check Focus —————————
-            val previousFocus = previousNode.unmergedConfig.getOrNull(Focused)
-            val currFocus = currNode.unmergedConfig.getOrNull(Focused)
-            if (previousFocus != true && currFocus == true) {
-                notifyViewEntered(id)
-                previouslyFocusedId = id
-            }
-            if (previousFocus == true && currFocus != true) {
-                notifyViewExited(id)
-            }
-
-            // Check Visibility —————————
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
-                val prevTransparency = previousNode.isTransparent
-                val currTransparency = currNode.isTransparent
-                if (prevTransparency != currTransparency) {
-                    notifyVisibilityChanged(id, currTransparency)
-                }
-            }
-        }
-    }
-
-    internal fun onSemanticsChange() {
-        currentSemanticsNodesInvalidated = true
-        if (!checkingForSemanticsChanges) {
-            checkingForSemanticsChanges = true
-            handler.post(autofillChangeChecker)
-        }
-    }
-
-    private fun notifyViewEntered(semanticsId: Int) {
-        currentSemanticsNodes[semanticsId]?.adjustedBounds?.let {
-            autofillManager.notifyViewEntered(semanticsId, it)
-        }
-    }
-
-    private fun notifyViewExited(semanticsId: Int) {
-        autofillManager.notifyViewExited(semanticsId)
-    }
-
-    private fun notifyAutofillValueChanged(semanticsId: Int, newAutofillValue: Any) {
-        val currSemanticsNode = currentSemanticsNodes[semanticsId]?.semanticsNode
-        val currDataType =
-            currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType) ?: return
-        when (currDataType) {
-            ContentDataType.Text ->
-                autofillManager.notifyValueChanged(
-                    semanticsId,
-                    AutofillValue.forText(newAutofillValue.toString())
-                )
-            ContentDataType.Date ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.Date")
-            ContentDataType.List ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.List")
-            ContentDataType.Toggle ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.Toggle")
-            else -> {
-                TODO("b/138604541: Add Autofill support for ContentDataType.None")
-            }
-        }
-    }
-
-    private fun notifyVisibilityChanged(semanticsId: Int, isInvisible: Boolean) {
-        autofillManager.notifyViewVisibilityChanged(semanticsId, !isInvisible)
+        rootAutofillId =
+            checkPreconditionNotNull(ViewCompatShims.getAutofillId(view)?.toAutofillId())
     }
 
     override fun commit() {
-        autofillManager.commit()
+        platformAutofillManager.commit()
     }
 
     override fun cancel() {
-        autofillManager.cancel()
+        platformAutofillManager.cancel()
     }
 
+    // This will be used to request autofill when
+    // `AutofillManager.requestAutofillForActiveElement()` is called (e.g. from the text toolbar).
+    private var previouslyFocusedId = -1
+
     override fun requestAutofillForActiveElement() {
-        currentSemanticsNodes[previouslyFocusedId]?.let {
-            autofillManager.requestAutofill(previouslyFocusedId, it.adjustedBounds)
+        if (previouslyFocusedId <= 0) return
+
+        rectManager.rects.withRect(previouslyFocusedId) { left, top, right, bottom ->
+            reusableRect.set(left, top, right, bottom)
+            platformAutofillManager.requestAutofill(view, previouslyFocusedId, reusableRect)
         }
     }
 
-    internal fun onTextFillHelper(toFillId: Int, autofillValue: String) {
-        // Use mapping to find lambda corresponding w semanticsNodeId,
-        // then invoke the lambda. This will change the field text.
-        val currSemanticsNode = currentSemanticsNodes[toFillId]?.semanticsNode
-        currSemanticsNode
-            ?.unmergedConfig
-            ?.getOrNull(OnAutofillText)
-            ?.action
-            ?.invoke(AnnotatedString(autofillValue))
-    }
+    /** Send events to the autofill service in response to semantics changes. */
+    override fun onSemanticsChanged(
+        semanticsInfo: SemanticsInfo,
+        previousSemanticsConfiguration: SemanticsConfiguration?
+    ) {
+        val config = semanticsInfo.semanticsConfiguration
+        val prevConfig = previousSemanticsConfiguration
+        val semanticsId = semanticsInfo.semanticsId
 
-    companion object {
-        /**
-         * Autofill Manager callback.
-         *
-         * This callback is called when we receive autofill events. It adds some logs that can be
-         * useful for debug purposes.
-         */
-        internal object AutofillSemanticCallback : PlatformAndroidManager.AutofillCallback() {
-            override fun onAutofillEvent(view: View, virtualId: Int, event: Int) {
-                super.onAutofillEvent(view, virtualId, event)
-                Log.d(
-                    "Autofill Status",
-                    when (event) {
-                        EVENT_INPUT_SHOWN -> "Autofill popup was shown."
-                        EVENT_INPUT_HIDDEN -> "Autofill popup was hidden."
-                        EVENT_INPUT_UNAVAILABLE ->
-                            """
-                        |Autofill popup isn't shown because autofill is not available.
-                        |
-                        |Did you set up autofill?
-                        |1. Go to Settings > System > Languages&input > Advanced > Autofill Service
-                        |2. Pick a service
-                        |
-                        |Did you add an account?
-                        |1. Go to Settings > System > Languages&input > Advanced
-                        |2. Click on the settings icon next to the Autofill Service
-                        |3. Add your account
-                        """
-                                .trimMargin()
-                        else -> "Unknown status event."
-                    }
+        // Check Editable Text.
+        val previousText = prevConfig?.getOrNull(SemanticsProperties.EditableText)?.text
+        val newText = config?.getOrNull(SemanticsProperties.EditableText)?.text
+        if (previousText != newText && newText != null) {
+            val contentDataType = config.getOrNull(SemanticsProperties.ContentDataType)
+            if (contentDataType == ContentDataType.Text) {
+                platformAutofillManager.notifyValueChanged(
+                    view,
+                    semanticsId,
+                    AutofillApi26Helper.getAutofillTextValue(newText.toString())
                 )
             }
+        }
 
-            /** Registers the autofill debug callback. */
-            fun register(androidAutofillManager: AndroidAutofillManager) {
-                androidAutofillManager.autofillManager.autofillManager.registerCallback(this)
+        // Check Focus.
+        // TODO: Instead of saving the focused item here, add some internal API to focusManager
+        //  so that this could be more efficient.
+        val previousFocus = prevConfig?.getOrNull(SemanticsProperties.Focused)
+        val currFocus = config?.getOrNull(SemanticsProperties.Focused)
+        val supportsAutofill = config?.getOrNull(SemanticsActions.OnAutofillText) != null
+        if (previousFocus != true && currFocus == true && supportsAutofill) {
+            previouslyFocusedId = semanticsId
+            rectManager.rects.withRect(semanticsId) { left, top, right, bottom ->
+                platformAutofillManager.notifyViewEntered(
+                    view,
+                    semanticsId,
+                    Rect(left, top, right, bottom)
+                )
             }
+        }
+        val previouslySupportedAutofill = config?.getOrNull(SemanticsActions.OnAutofillText) != null
+        if (previousFocus == true && currFocus != true && previouslySupportedAutofill) {
+            platformAutofillManager.notifyViewExited(view, semanticsId)
+        }
 
-            /** Unregisters the autofill debug callback. */
-            fun unregister(androidAutofillManager: AndroidAutofillManager) {
-                androidAutofillManager.autofillManager.autofillManager.unregisterCallback(this)
+        // Update currentlyDisplayedIDs if relevance to Autofill has changed.
+        val prevRelatedToAutofill = prevConfig?.isRelatedToAutofill()
+        val currRelatedToAutofill = config?.isRelatedToAutofill()
+        if (prevRelatedToAutofill != currRelatedToAutofill) {
+            if (currRelatedToAutofill == true) {
+                currentlyDisplayedIDs.add(semanticsId)
+            } else {
+                currentlyDisplayedIDs.remove(semanticsId)
             }
+            executeAutoCommit()
         }
     }
 
-    override fun onViewAttachedToWindow(v: View) {}
+    /** Populate the structure of the entire view hierarchy when the framework requests it. */
+    fun populateViewStructure(rootViewStructure: ViewStructure) {
+        val autofillApi = AutofillApi26Helper
+        val rootSemanticInfo = semanticsOwner.rootInfo
 
-    override fun onViewDetachedFromWindow(v: View) {
-        handler.removeCallbacks(autofillChangeChecker)
-    }
-}
+        // Populate view structure for the root.
+        rootViewStructure.populate(rootSemanticInfo, rootAutofillId, packageName, rectManager)
 
-@RequiresApi(Build.VERSION_CODES.O)
-internal fun AndroidAutofillManager.populateViewStructure(root: ViewStructure) {
-    // Add child nodes. The function returns the index to the first item.
-    val count =
-        currentSemanticsNodes.count { _, semanticsNodeWithAdjustedBounds ->
-            // TODO(333102566): remove the `isRelatedToAutofill` check below
-            //  for heuristics based autofill support.
-            semanticsNodeWithAdjustedBounds.semanticsNode.unmergedConfig.contains(ContentType) ||
-                semanticsNodeWithAdjustedBounds.semanticsNode.unmergedConfig.contains(
-                    SemanticsContentDataType
-                )
-        }
+        // We save the semanticInfo and viewStructure of the item in a list. These are always stored
+        // as pairs, and we need to cast them back to the required types when we extract them.
+        val populateChildren = mutableObjectListOf<Any>(rootSemanticInfo, rootViewStructure)
 
-    // TODO(b/138549623): Instead of creating a flattened tree by using the nodes from the map, we
-    //  can use SemanticsOwner to get the root SemanticsInfo and create a more representative tree.
-    var index = AutofillApi26Helper.addChildCount(root, count)
+        @Suppress("Range") // isNotEmpty ensures removeAt is not called with -1.
+        while (populateChildren.isNotEmpty()) {
 
-    // Iterate through currentSemanticsNodes, finding autofill-related nodes
-    // and call corresponding APIs on the viewStructure as listed above
-    currentSemanticsNodes.forEach { semanticsId, adjustedNode ->
-        if (
-            adjustedNode.semanticsNode.unmergedConfig.contains(ContentType) ||
-                adjustedNode.semanticsNode.unmergedConfig.contains(SemanticsContentDataType)
-        ) {
-            AutofillApi26Helper.newChild(root, index)?.also { child ->
-                AutofillApi26Helper.setAutofillId(
-                    child,
-                    AutofillApi26Helper.getAutofillId(root)!!,
-                    semanticsId
-                )
-                AutofillApi26Helper.setId(child, semanticsId, view.context.packageName, null, null)
+            val parentStructure =
+                populateChildren.removeAt(populateChildren.lastIndex) as ViewStructure
+            val parentInfo = populateChildren.removeAt(populateChildren.lastIndex) as SemanticsInfo
 
-                adjustedNode.semanticsNode.unmergedConfig.getOrNull(SemanticsContentDataType)?.let {
-                    AutofillApi26Helper.setAutofillTypeForViewStruct(child, it)
+            parentInfo.childrenInfo.fastForEach { childInfo ->
+                if (childInfo.isDeactivated || !childInfo.isAttached || !childInfo.isPlaced) {
+                    return@fastForEach
                 }
 
-                adjustedNode.semanticsNode.unmergedConfig
-                    .getOrNull(ContentType)
-                    ?.contentHints
-                    ?.toTypedArray()
-                    ?.let { AutofillApi26Helper.setAutofillHints(child, it) }
-
-                adjustedNode.adjustedBounds.run {
-                    AutofillApi26Helper.setDimens(child, left, top, 0, 0, width(), height())
+                // TODO(b/378160001): For now we only populate autofill-able nodes. Populate the
+                //  structure for all nodes in the future.
+                val semanticsConfigurationChild = childInfo.semanticsConfiguration
+                if (semanticsConfigurationChild?.isRelatedToAutofill() != true) {
+                    populateChildren.add(childInfo)
+                    populateChildren.add(parentStructure)
+                    return@fastForEach
                 }
 
-                adjustedNode.semanticsNode.populateViewStructure(child)
+                val childIndex = autofillApi.addChildCount(parentStructure, 1)
+                val childStructure = autofillApi.newChild(parentStructure, childIndex)
+                childStructure.populate(childInfo, rootAutofillId, packageName, rectManager)
+                populateChildren.add(childInfo)
+                populateChildren.add(childStructure)
             }
-            index++
         }
     }
+
+    /** When the autofill service provides data, perform autofill using semantic actions. */
+    fun performAutofill(values: SparseArray<AutofillValue>) {
+        for (index in 0 until values.size()) {
+            val itemId = values.keyAt(index)
+            val value = values[itemId]
+            when {
+                AutofillApi26Helper.isText(value) ->
+                    semanticsOwner[itemId]
+                        ?.semanticsConfiguration
+                        ?.getOrNull(SemanticsActions.OnAutofillText)
+                        ?.action
+                        ?.invoke(AnnotatedString(AutofillApi26Helper.textValue(value).toString()))
+
+                // TODO(b/138604541): Add Autofill support for date fields.
+                AutofillApi26Helper.isDate(value) ->
+                    Log.w(logTag, "Auto filling Date fields is not yet supported.")
+
+                // TODO(b/138604541): Add Autofill support for dropdown lists.
+                AutofillApi26Helper.isList(value) ->
+                    Log.w(logTag, "Auto filling dropdown lists is not yet supported.")
+
+                // TODO(b/138604541): Add Autofill support for toggle fields.
+                AutofillApi26Helper.isToggle(value) ->
+                    Log.w(logTag, "Auto filling toggle fields are not yet supported.")
+            }
+        }
+    }
+
+    // Consider moving the currently displayed IDs to a separate VisibilityManager class. This might
+    // be needed by ContentCapture and Accessibility.
+    private var currentlyDisplayedIDs = MutableIntSet()
+    private var pendingChangesToDisplayedIds = false
+
+    internal fun onPostAttach(semanticsInfo: SemanticsInfo) {
+        if (semanticsInfo.semanticsConfiguration?.isRelatedToAutofill() == true) {
+            currentlyDisplayedIDs.add(semanticsInfo.semanticsId)
+            pendingChangesToDisplayedIds = true
+            // TODO(MNUZEN): Notify autofill manager that a node has been added.
+            // platformAutofillManager
+            //     .notifyViewVisibilityChanged(view, semanticsInfo.semanticsId, true)
+        }
+    }
+
+    internal fun onDetach(semanticsInfo: SemanticsInfo) {
+        if (semanticsInfo.semanticsConfiguration?.isRelatedToAutofill() == true) {
+            currentlyDisplayedIDs.remove(semanticsInfo.semanticsId)
+            pendingChangesToDisplayedIds = true
+            // TODO(MNUZEN): Notify autofill manager that a node has been removed.
+            // platformAutofillManager
+            //     .notifyViewVisibilityChanged(view, semanticsInfo.semanticsId, false)
+        }
+    }
+
+    internal fun onEndApplyChanges() {
+        if (pendingChangesToDisplayedIds) {
+            executeAutoCommit()
+            pendingChangesToDisplayedIds = false
+        }
+    }
+
+    // We maintain a copy of the previously displayed IDs, and call AutofillManager.commit() when
+    // all the previously displayed IDs were removed.
+    private var previouslyDisplayedIDs = MutableIntSet()
+
+    private fun executeAutoCommit() {
+        // Check for screen changes or complete removal.
+        if (!currentlyDisplayedIDs.containsAll(previouslyDisplayedIDs)) {
+            platformAutofillManager.commit()
+        }
+        previouslyDisplayedIDs.copyFrom(currentlyDisplayedIDs)
+    }
 }
 
-@RequiresApi(Build.VERSION_CODES.O)
-internal fun SemanticsNode.populateViewStructure(child: ViewStructure) {
-    // ———————— Interactions (clicking, checking, selecting, etc.)
-    AutofillApi26Helper.setClickable(child, unmergedConfig.contains(OnClick))
-    AutofillApi26Helper.setCheckable(child, unmergedConfig.contains(ToggleableState))
-    AutofillApi26Helper.setEnabled(child, (!config.contains(Disabled)))
-    AutofillApi26Helper.setFocused(child, unmergedConfig.getOrNull(Focused) == true)
-    AutofillApi26Helper.setFocusable(child, unmergedConfig.contains(Focused))
-    AutofillApi26Helper.setLongClickable(child, unmergedConfig.contains(OnLongClick))
-    unmergedConfig.getOrNull(Selected)?.let { AutofillApi26Helper.setSelected(child, it) }
-
-    unmergedConfig.getOrNull(ToggleableState)?.let {
-        AutofillApi26Helper.setChecked(child, it == On)
-    }
-    // TODO(MNUZEN): Set setAccessibilityFocused as well
-
-    // ———————— Visibility, elevation, alpha
-    // Transparency should be the only thing affecting View.VISIBLE (pruning will take care of all
-    // covered nodes).
-    // TODO(mnuzen): since we are removing pruning in semantics/accessibility with `semanticInfo`,
-    // double check that this is the correct behavior even after switching.
-    AutofillApi26Helper.setVisibility(
-        child,
-        if (!isTransparent || isRoot) View.VISIBLE else View.INVISIBLE
-    )
-
-    // TODO(335726351): will call the below method when b/335726351 has been fulfilled and
-    // `isOpaque` is added back.
-    // AutofillApi26Helper.setOpaque(child, isOpaque)
-
-    // ———————— Text, role, content description
-    config.getOrNull(Text)?.let { textList ->
-        var concatenatedText = ""
-        textList.fastForEach { text -> concatenatedText += text.text + "\n" }
-        AutofillApi26Helper.setText(child, concatenatedText)
-        AutofillApi26Helper.setClassName(child, TextClassName)
-    }
-
-    unmergedConfig.getOrNull(MaxTextLength)?.let {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-            AutofillApi28Helper.setMaxTextLength(child, it)
-        }
-    }
-
-    val role = unmergedConfig.getOrNull(Role)
-    role?.let {
-        if (isFake || replacedChildren.isEmpty()) {
-            AutofillApi26Helper.setClassName(child, it.toLegacyClassName())
-        }
-    }
-
-    if (unmergedConfig.contains(SetText)) {
-        // If `SetText` action exists, then the element is a TextField
-        AutofillApi26Helper.setClassName(child, TextFieldClassName)
-        // If the element is a TextField, then we also want to set the current text value for
-        // autofill. (This data is used when we save autofilled values.)
-        unmergedConfig.getOrNull(Text)?.let { textList ->
-            var concatenatedText = ""
-            textList.fastForEach { text -> concatenatedText += text.text + "\n" }
-            AutofillApi26Helper.setAutofillValue(
-                child,
-                AutofillApi26Helper.getAutofillTextValue(concatenatedText)
-            )
-        }
-    }
-
-    unmergedConfig.getOrNull(Selected)?.let {
-        if (role == Tab) {
-            AutofillApi26Helper.setSelected(child, it)
-        } else {
-            AutofillApi26Helper.setCheckable(child, true)
-            AutofillApi26Helper.setChecked(child, it)
-        }
-    }
-
-    unmergedConfig.getOrNull(ContentDescription)?.firstOrNull()?.let {
-        AutofillApi26Helper.setContentDescription(child, it)
-    }
-
-    // ———————— Parsing autofill hints and types
-    // If there is no explicitly set data type, parse it from semantics.
-    if (unmergedConfig.contains(SetText)) {
-        if (!unmergedConfig.contains(SemanticsContentDataType)) {
-            AutofillApi26Helper.setAutofillType(child, View.AUTOFILL_TYPE_TEXT)
-        }
-    }
-
-    // If it's a password, setInputType and the sensitive flag
-    if (unmergedConfig.contains(Password)) {
-        AutofillApi26Helper.setInputType(
-            child,
-            InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
-        )
-        AutofillApi26Helper.setDataIsSensitive(child, true)
-    }
-
-    // if the toggleableState is not null, set autofillType to AUTOFILL_TYPE_TOGGLE
-    if (unmergedConfig.contains(ToggleableState)) {
-        AutofillApi26Helper.setAutofillType(child, View.AUTOFILL_TYPE_TOGGLE)
-    }
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-internal fun AndroidAutofillManager.performAutofill(values: SparseArray<AutofillValue>) {
-    for (index in 0 until values.size()) {
-        val itemId = values.keyAt(index)
-        val value = values[itemId]
-        when {
-            AutofillApi26Helper.isText(value) -> // passing in the autofillID
-            onTextFillHelper(itemId, AutofillApi26Helper.textValue(value).toString())
-            AutofillApi26Helper.isDate(value) ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.Date")
-            AutofillApi26Helper.isList(value) ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.List")
-            AutofillApi26Helper.isToggle(value) ->
-                TODO("b/138604541: Add Autofill support for ContentDataType.Toggle")
-        }
-    }
-}
-
-/** Wrapper for the final AutofillManager class. This can be mocked in testing. */
-@RequiresApi(Build.VERSION_CODES.O)
-internal interface AutofillManagerWrapper {
-    val autofillManager: PlatformAndroidManager
-
-    fun notifyViewEntered(semanticsId: Int, bounds: Rect)
-
-    fun notifyViewExited(semanticsId: Int)
-
-    fun notifyValueChanged(semanticsId: Int, autofillValue: AutofillValue)
-
-    fun notifyViewVisibilityChanged(semanticsId: Int, isVisible: Boolean)
-
-    fun commit()
-
-    fun cancel()
-
-    fun requestAutofill(semanticsId: Int, bounds: Rect)
-}
-
-@RequiresApi(Build.VERSION_CODES.O)
-private class AutofillManagerWrapperImpl(val view: View) : AutofillManagerWrapper {
-    override val autofillManager =
-        view.context.getSystemService(PlatformAndroidManager::class.java)
-            ?: error("Autofill service could not be located.")
-
-    override fun notifyViewEntered(semanticsId: Int, bounds: Rect) {
-        autofillManager.notifyViewEntered(view, semanticsId, bounds)
-    }
-
-    override fun notifyViewExited(semanticsId: Int) {
-        autofillManager.notifyViewExited(view, semanticsId)
-    }
-
-    override fun notifyValueChanged(semanticsId: Int, autofillValue: AutofillValue) {
-        autofillManager.notifyValueChanged(view, semanticsId, autofillValue)
-    }
-
-    override fun notifyViewVisibilityChanged(semanticsId: Int, isVisible: Boolean) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
-            AutofillApi27Helper.notifyViewVisibilityChanged(
-                view,
-                autofillManager,
-                semanticsId,
-                isVisible
-            )
-        }
-    }
-
-    override fun commit() {
-        autofillManager.commit()
-    }
-
-    override fun cancel() {
-        autofillManager.cancel()
-    }
-
-    override fun requestAutofill(semanticsId: Int, bounds: Rect) {
-        autofillManager.requestAutofill(view, semanticsId, bounds)
-    }
+private fun SemanticsConfiguration.isRelatedToAutofill(): Boolean {
+    return props.contains(SemanticsActions.OnAutofillText) ||
+        props.contains(SemanticsProperties.ContentType) ||
+        props.contains(SemanticsProperties.ContentDataType)
 }
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillDebugUtils.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillDebugUtils.android.kt
new file mode 100644
index 0000000..8656b41
--- /dev/null
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillDebugUtils.android.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.autofill
+
+import android.os.Build
+import android.util.Log
+import android.view.View
+import android.view.autofill.AutofillManager
+import androidx.annotation.RequiresApi
+
+/**
+ * Autofill Manager callback.
+ *
+ * This callback is called when we receive autofill events. It adds some logs that can be useful for
+ * debug purposes.
+ */
+@RequiresApi(Build.VERSION_CODES.O)
+internal object AutofillSemanticCallback : AutofillManager.AutofillCallback() {
+    override fun onAutofillEvent(view: View, virtualId: Int, event: Int) {
+        super.onAutofillEvent(view, virtualId, event)
+        Log.d(
+            "Autofill Status",
+            when (event) {
+                EVENT_INPUT_SHOWN -> "Autofill popup was shown."
+                EVENT_INPUT_HIDDEN -> "Autofill popup was hidden."
+                EVENT_INPUT_UNAVAILABLE ->
+                    """
+                        |Autofill popup isn't shown because autofill is not available.
+                        |
+                        |Did you set up autofill?
+                        |1. Go to Settings > System > Languages&input > Advanced > Autofill Service
+                        |2. Pick a service
+                        |
+                        |Did you add an account?
+                        |1. Go to Settings > System > Languages&input > Advanced
+                        |2. Click on the settings icon next to the Autofill Service
+                        |3. Add your account
+                        """
+                        .trimMargin()
+                else -> "Unknown status event."
+            }
+        )
+    }
+
+    /** Registers the autofill debug callback. */
+    fun register(androidAutofillManager: AndroidAutofillManager) {
+        (androidAutofillManager.platformAutofillManager as PlatformAutofillManagerImpl)
+            .platformAndroidManager
+            .registerCallback(this)
+    }
+
+    /** Unregisters the autofill debug callback. */
+    fun unregister(androidAutofillManager: AndroidAutofillManager) {
+        (androidAutofillManager.platformAutofillManager as PlatformAutofillManagerImpl)
+            .platformAndroidManager
+            .unregisterCallback(this)
+    }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillUtils.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillUtils.android.kt
index 6309c56..60bf15f 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillUtils.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AutofillUtils.android.kt
@@ -22,8 +22,7 @@
 import android.view.autofill.AutofillManager
 import android.view.autofill.AutofillValue
 import androidx.annotation.RequiresApi
-import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.ClassName
-import androidx.compose.ui.semantics.Role
+import androidx.collection.MutableIntSet
 
 /**
  * This class is here to ensure that the classes that use this API will get verified and can be AOT
@@ -62,7 +61,7 @@
 @RequiresApi(26)
 internal object AutofillApi26Helper {
     @RequiresApi(26)
-    fun newChild(structure: ViewStructure, index: Int): ViewStructure? = structure.newChild(index)
+    fun newChild(structure: ViewStructure, index: Int): ViewStructure = structure.newChild(index)
 
     @RequiresApi(26)
     fun addChildCount(structure: ViewStructure, num: Int) = structure.addChildCount(num)
@@ -183,28 +182,14 @@
     fun getAutofillTextValue(value: String): AutofillValue {
         return AutofillValue.forText(value)
     }
-
-    @RequiresApi(26)
-    fun setAutofillTypeForViewStruct(child: ViewStructure, dataType: ContentDataType) {
-        val autofillType =
-            when (dataType) {
-                ContentDataType.Text -> View.AUTOFILL_TYPE_TEXT
-                ContentDataType.Date -> View.AUTOFILL_TYPE_DATE
-                ContentDataType.Toggle -> View.AUTOFILL_TYPE_TOGGLE
-                ContentDataType.List -> View.AUTOFILL_TYPE_LIST
-                else -> View.AUTOFILL_TYPE_NONE
-            }
-        setAutofillType(child, autofillType)
-    }
 }
 
-internal fun Role.toLegacyClassName(): String =
-    when (this) {
-        Role.Button -> "android.widget.Button"
-        Role.Checkbox -> "android.widget.CheckBox"
-        Role.RadioButton -> "android.widget.RadioButton"
-        Role.Image -> "android.widget.ImageView"
-        Role.DropdownList -> "android.widget.Spinner"
-        Role.ValuePicker -> "android.widget.NumberPicker"
-        else -> ClassName
-    }
+// Copy all elements from `other` to `this`.
+internal fun MutableIntSet.copyFrom(other: MutableIntSet) {
+    this.clear()
+    this.addAll(other)
+}
+
+internal fun MutableIntSet.containsAll(other: MutableIntSet): Boolean {
+    return other.all { this.contains(it) }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PlatformAutofillManager.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PlatformAutofillManager.android.kt
new file mode 100644
index 0000000..3923fa4
--- /dev/null
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PlatformAutofillManager.android.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.autofill
+
+import android.graphics.Rect
+import android.os.Build
+import android.view.View
+import android.view.autofill.AutofillManager
+import android.view.autofill.AutofillValue
+import androidx.annotation.RequiresApi
+
+/** Wrapper for the final AutofillManager class. This can be mocked in testing. */
+@RequiresApi(Build.VERSION_CODES.O)
+internal interface PlatformAutofillManager {
+    fun notifyViewEntered(view: View, semanticsId: Int, bounds: Rect)
+
+    fun notifyViewExited(view: View, semanticsId: Int)
+
+    fun notifyValueChanged(view: View, semanticsId: Int, autofillValue: AutofillValue)
+
+    fun notifyViewVisibilityChanged(view: View, semanticsId: Int, isVisible: Boolean)
+
+    fun commit()
+
+    fun cancel()
+
+    fun requestAutofill(view: View, semanticsId: Int, bounds: Rect)
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+internal class PlatformAutofillManagerImpl(val platformAndroidManager: AutofillManager) :
+    PlatformAutofillManager {
+
+    override fun notifyViewEntered(view: View, semanticsId: Int, bounds: Rect) {
+        platformAndroidManager.notifyViewEntered(view, semanticsId, bounds)
+    }
+
+    override fun notifyViewExited(view: View, semanticsId: Int) {
+        platformAndroidManager.notifyViewExited(view, semanticsId)
+    }
+
+    override fun notifyValueChanged(view: View, semanticsId: Int, autofillValue: AutofillValue) {
+        platformAndroidManager.notifyValueChanged(view, semanticsId, autofillValue)
+    }
+
+    override fun notifyViewVisibilityChanged(view: View, semanticsId: Int, isVisible: Boolean) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+            AutofillApi27Helper.notifyViewVisibilityChanged(
+                view,
+                platformAndroidManager,
+                semanticsId,
+                isVisible
+            )
+        }
+    }
+
+    override fun commit() {
+        platformAndroidManager.commit()
+    }
+
+    override fun cancel() {
+        platformAndroidManager.cancel()
+    }
+
+    override fun requestAutofill(view: View, semanticsId: Int, bounds: Rect) {
+        platformAndroidManager.requestAutofill(view, semanticsId, bounds)
+    }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PopulateViewStructure.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PopulateViewStructure.android.kt
new file mode 100644
index 0000000..db2789e
--- /dev/null
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/PopulateViewStructure.android.kt
@@ -0,0 +1,195 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.autofill
+
+import android.os.Build
+import android.text.InputType
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewStructure
+import android.view.autofill.AutofillId
+import android.view.inputmethod.EditorInfo
+import androidx.annotation.RequiresApi
+import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextClassName
+import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName
+import androidx.compose.ui.platform.toLegacyClassName
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsInfo
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.mergedSemanticsConfiguration
+import androidx.compose.ui.spatial.RectManager
+import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.state.ToggleableState.On
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.util.fastForEach
+import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
+
+@RequiresApi(Build.VERSION_CODES.O)
+internal fun ViewStructure.populate(
+    semanticsInfo: SemanticsInfo,
+    rootAutofillId: AutofillId,
+    packageName: String?,
+    rectManager: RectManager
+) {
+    val autofillApi = AutofillApi26Helper
+    val properties = SemanticsProperties
+    val actions = SemanticsActions
+
+    // Semantics properties.
+    var contentDataTypeProp: ContentDataType? = null
+    var contentTypeProp: ContentType? = null
+    var editableTextProp: AnnotatedString? = null
+    var isPasswordProp = false
+    var maxTextLengthProp: Int? = null
+    var roleProp: Role? = null
+    var selectedProp: Boolean? = null
+    var toggleableStateProp: ToggleableState? = null
+
+    // Semantics properties form merged configuration.
+    var disabledMergedProp: Boolean? = null
+    var textMergedProp: List<AnnotatedString>? = null
+
+    // Semantics actions.
+    var hasSetTextAction = false
+
+    semanticsInfo.semanticsConfiguration?.props?.forEach { property, value ->
+        @Suppress("UNCHECKED_CAST")
+        when (property) {
+            properties.ContentDataType -> contentDataTypeProp = value as ContentDataType
+            properties.ContentDescription ->
+                (value as List<String>).firstOrNull()?.let {
+                    autofillApi.setContentDescription(this, it)
+                }
+            properties.ContentType -> contentTypeProp = value as ContentType
+            properties.EditableText -> editableTextProp = value as AnnotatedString
+            properties.Focused -> autofillApi.setFocused(this, value as Boolean)
+            properties.MaxTextLength -> maxTextLengthProp = value as Int
+            properties.Password -> isPasswordProp = true
+            properties.Role -> roleProp = value as Role
+            properties.Selected -> selectedProp = value as Boolean
+            properties.ToggleableState -> toggleableStateProp = value as ToggleableState
+            actions.OnClick -> autofillApi.setClickable(this, true)
+            actions.OnLongClick -> autofillApi.setLongClickable(this, true)
+            actions.RequestFocus -> autofillApi.setFocusable(this, true)
+            actions.SetText -> hasSetTextAction = true
+        }
+    }
+
+    semanticsInfo.mergedSemanticsConfiguration()?.props?.forEach { property, value ->
+        @Suppress("UNCHECKED_CAST")
+        when (property) {
+            properties.Disabled -> disabledMergedProp = value as Boolean
+            properties.Text -> textMergedProp = value as List<AnnotatedString>
+        }
+    }
+
+    // Id.
+    val semanticsId =
+        semanticsInfo.semanticsId.takeUnless { semanticsInfo.parentInfo == null }
+            ?: AccessibilityNodeProviderCompat.HOST_VIEW_ID
+    autofillApi.setAutofillId(this, rootAutofillId, semanticsId)
+    autofillApi.setId(this, semanticsId, packageName, null, null)
+
+    // Autofill Type.
+    val autofillType =
+        contentDataTypeProp?.dataType
+            ?: when {
+                hasSetTextAction -> View.AUTOFILL_TYPE_TEXT
+                toggleableStateProp != null -> View.AUTOFILL_TYPE_TOGGLE
+                else -> null
+            }
+    autofillType?.let { autofillApi.setAutofillType(this, it) }
+
+    // Autofill Hints.
+    contentTypeProp?.contentHints?.let { autofillApi.setAutofillHints(this, it.toTypedArray()) }
+
+    // Dimensions.
+    rectManager.rects.withRect(semanticsInfo.semanticsId) { left, top, right, bottom ->
+        autofillApi.setDimens(this, left, top, 0, 0, right - left, bottom - top)
+    }
+
+    // Enabled.
+    autofillApi.setEnabled(this, disabledMergedProp != true)
+
+    // Selected.
+    selectedProp?.let { autofillApi.setSelected(this, it) }
+
+    // Checkable.
+    val toggleableState = toggleableStateProp
+    val selected = selectedProp
+    if (toggleableState != null) {
+        autofillApi.setCheckable(this, true)
+        autofillApi.setChecked(this, toggleableState == On)
+    } else if (selected != null && roleProp != Role.Tab) {
+        autofillApi.setCheckable(this, true)
+        autofillApi.setChecked(this, selected)
+    }
+
+    // Password.
+    val passwordHint = ContentType.Password.contentHints.first()
+    val contentTypePassword = contentTypeProp?.contentHints?.contains(passwordHint) == true
+    val isPassword = isPasswordProp || contentTypePassword
+    if (isPassword) {
+        autofillApi.setDataIsSensitive(this, true)
+    }
+
+    // Visibility.
+    // TODO(b/383198004): This only checks transparency. We should also check whether the layoutNode
+    //  is within visible bounds.
+    autofillApi.setVisibility(this, if (semanticsInfo.isTransparent()) INVISIBLE else VISIBLE)
+
+    // TODO(335726351): will call the below method when b/335726351 has been fulfilled and
+    // `isOpaque` is added back.
+    // autofillApi.setOpaque(this, isOpaque)
+
+    // Text.
+    textMergedProp?.let {
+        var concatenatedText = ""
+        it.fastForEach { text -> concatenatedText += text.text + "\n" }
+        autofillApi.setText(this, concatenatedText)
+        autofillApi.setClassName(this, TextClassName)
+    }
+
+    // Role.
+    if (semanticsInfo.childrenInfo.isEmpty()) {
+        roleProp?.toLegacyClassName()?.let { autofillApi.setClassName(this, it) }
+    }
+
+    // TextField.
+    if (hasSetTextAction) {
+        autofillApi.setClassName(this, TextFieldClassName)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            maxTextLengthProp?.let { AutofillApi28Helper.setMaxTextLength(this, it) }
+        }
+
+        // Set the current value for autofill. (Used to save values during autofill commit).
+        editableTextProp?.let {
+            autofillApi.setAutofillValue(this, autofillApi.getAutofillTextValue(it.text))
+        }
+
+        // Password.
+        if (isPassword) {
+            autofillApi.setInputType(
+                this,
+                InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
+            )
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index fd055b2..dbb41be 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -55,6 +55,7 @@
 import android.view.ViewTreeObserver
 import android.view.accessibility.AccessibilityNodeInfo
 import android.view.animation.AnimationUtils
+import android.view.autofill.AutofillManager as PlatformAndroidManager
 import android.view.autofill.AutofillValue
 import android.view.inputmethod.EditorInfo
 import android.view.inputmethod.InputConnection
@@ -74,7 +75,6 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
@@ -85,6 +85,7 @@
 import androidx.compose.ui.autofill.AutofillCallback
 import androidx.compose.ui.autofill.AutofillManager
 import androidx.compose.ui.autofill.AutofillTree
+import androidx.compose.ui.autofill.PlatformAutofillManagerImpl
 import androidx.compose.ui.autofill.performAutofill
 import androidx.compose.ui.autofill.populateViewStructure
 import androidx.compose.ui.contentcapture.AndroidContentCaptureManager
@@ -107,6 +108,7 @@
 import androidx.compose.ui.focus.focusRect
 import androidx.compose.ui.focus.is1dFocusSearch
 import androidx.compose.ui.focus.isBetterCandidate
+import androidx.compose.ui.focus.requestFocus
 import androidx.compose.ui.focus.requestInteropFocus
 import androidx.compose.ui.focus.toAndroidFocusDirection
 import androidx.compose.ui.focus.toFocusDirection
@@ -325,90 +327,28 @@
     override val windowInfo: WindowInfo
         get() = _windowInfo
 
-    /**
-     * Because AndroidComposeView always accepts focus, we have to divert focus to another View if
-     * there is nothing focusable within. However, if there are only nonfocusable ComposeViews, then
-     * the redirection can recurse infinitely. This makes sure that if that happens, then it can
-     * bail when it is detected
-     */
-    private var processingRequestFocusForNextNonChildView = false
-
     // When move focus is triggered by a key event, and move focus does not cause any focus change,
     // we return the key event to the view system if focus search finds a suitable view which is not
     // a compose sub-view. However if move focus is triggered programmatically, we have to manually
     // implement this behavior because the view system does not have a moveFocus API.
     private fun onMoveFocusInChildren(focusDirection: FocusDirection): Boolean {
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (!ComposeUiFlags.isViewFocusFixEnabled) {
-            // The view system does not have an API corresponding to Enter/Exit.
-            if (focusDirection == Enter || focusDirection == Exit) return false
 
-            val direction =
-                checkNotNull(focusDirection.toAndroidFocusDirection()) { "Invalid focus direction" }
-            val focusedRect = onFetchFocusRect()?.toAndroidRect()
-
-            val nextView =
-                FocusFinder.getInstance().let {
-                    if (focusedRect == null) {
-                        it.findNextFocus(this, findFocus(), direction)
-                    } else {
-                        it.findNextFocusFromRect(this, focusedRect, direction)
-                    }
-                }
-            return nextView?.requestInteropFocus(direction, focusedRect) ?: false
-        }
         // The view system does not have an API corresponding to Enter/Exit.
-        if (focusDirection == Enter || focusDirection == Exit || !hasFocus()) return false
-
-        val androidViewsHandler = _androidViewsHandler ?: return false
+        if (focusDirection == Enter || focusDirection == Exit) return false
 
         val direction =
             checkNotNull(focusDirection.toAndroidFocusDirection()) { "Invalid focus direction" }
+        val focusedRect = onFetchFocusRect()?.toAndroidRect()
 
-        val root = rootView as ViewGroup
-
-        val currentFocus = root.findFocus() ?: error("view hasFocus but root can't find it")
-
-        val focusFinder = FocusFinder.getInstance()
-        val nextView: View?
-        val focusedRect: Rect?
-        if (focusDirection.is1dFocusSearch() && androidViewsHandler.hasFocus()) {
-            focusedRect = null
-            if (SDK_INT >= O) {
-                // On newer devices, the focus is normal and we can expect forward/next to work
-                nextView = focusFinder.findNextFocus(root, currentFocus, direction)
-            } else {
-                // On older devices, FocusFinder doesn't properly order Views, so we have to use
-                // a copy of the focus finder the corrects the order
-                nextView = FocusFinderCompat.instance.findNextFocus1d(root, currentFocus, direction)
+        val nextView =
+            FocusFinder.getInstance().let {
+                if (focusedRect == null) {
+                    it.findNextFocus(this, findFocus(), direction)
+                } else {
+                    it.findNextFocusFromRect(this, focusedRect, direction)
+                }
             }
-        } else {
-            focusedRect = onFetchFocusRect()?.toAndroidRect()
-            nextView = focusFinder.findNextFocusFromRect(root, focusedRect, direction)
-            nextView?.getLocationInWindow(tmpPositionArray)
-            val nextPositionX = tmpPositionArray[0]
-            val nextPositionY = tmpPositionArray[1]
-            getLocationInWindow(tmpPositionArray)
-            focusedRect?.offset(
-                tmpPositionArray[0] - nextPositionX,
-                tmpPositionArray[1] - nextPositionY
-            )
-        }
-
-        // is it part of the compose hierarchy?
-        if (nextView == null || nextView === currentFocus) {
-            return false
-        }
-
-        val focusedChild = androidViewsHandler.focusedChild
-        var nextParent = nextView.parent
-        while (nextParent != null && nextParent !== focusedChild) {
-            nextParent = nextParent.parent
-        }
-        if (nextParent == null) {
-            return false // not a part of the compose hierarchy
-        }
-        return nextView.requestInteropFocus(direction, focusedRect)
+        return nextView?.requestInteropFocus(direction, focusedRect) ?: false
     }
 
     // If this root view is focused, we can get the focus rect from focusOwner. But if a sub-view
@@ -429,16 +369,6 @@
             val focusDirection = getFocusDirection(keyEvent)
             if (focusDirection == null || keyEvent.type != KeyDown) return@onKeyEvent false
 
-            val androidDirection = focusDirection.toAndroidFocusDirection()
-
-            @OptIn(ExperimentalComposeUiApi::class)
-            if (ComposeUiFlags.isViewFocusFixEnabled) {
-                if (hasFocus() && androidDirection != null) {
-                    // A child AndroidView is focused. See if the view has a child that should be
-                    // focused next.
-                    if (onMoveFocusInChildren(focusDirection)) return@onKeyEvent true
-                }
-            }
             val focusedRect = onFetchFocusRect()
 
             // Consume the key event if we moved focus or if focus search or requestFocus is
@@ -459,22 +389,13 @@
             // this view. We don't return false because we don't want to re-visit sub-views. They
             // will
             // instead be visited when the AndroidView around them gets a moveFocus(Enter)).
+            val androidDirection =
+                checkNotNull(focusDirection.toAndroidFocusDirection()) { "Invalid focus direction" }
+            val androidRect = checkNotNull(focusedRect?.toAndroidRect()) { "Invalid rect" }
 
-            if (androidDirection != null) {
-                val nextView = findNextNonChildView(androidDirection).takeIf { it != this }
-                if (nextView != null) {
-                    val androidRect = checkNotNull(focusedRect?.toAndroidRect()) { "Invalid rect" }
-                    nextView.getLocationInWindow(tmpPositionArray)
-                    val nextX = tmpPositionArray[0]
-                    val nextY = tmpPositionArray[1]
-                    getLocationInWindow(tmpPositionArray)
-                    val currentX = tmpPositionArray[0]
-                    val currentY = tmpPositionArray[1]
-                    androidRect.offset(currentX - nextX, currentY - nextY)
-                    if (nextView.requestInteropFocus(androidDirection, androidRect)) {
-                        return@onKeyEvent true
-                    }
-                }
+            val nextView = findNextNonChildView(androidDirection).takeIf { it != this }
+            if (nextView != null && nextView.requestInteropFocus(androidDirection, androidRect)) {
+                return@onKeyEvent true
             }
 
             // Focus finder couldn't find another view. We manually wrap around since focus remained
@@ -498,9 +419,10 @@
 
     private fun findNextNonChildView(direction: Int): View? {
         var currentView: View? = this
-        val focusFinder = FocusFinder.getInstance()
         while (currentView != null) {
-            currentView = focusFinder.findNextFocus(rootView as ViewGroup, currentView, direction)
+            currentView =
+                FocusFinder.getInstance()
+                    .findNextFocus(rootView as ViewGroup, currentView, direction)
             if (currentView != null && !containsDescendant(currentView)) return currentView
         }
         return null
@@ -534,6 +456,8 @@
 
     override val layoutNodes: MutableIntObjectMap<LayoutNode> = mutableIntObjectMapOf()
 
+    override val rectManager = RectManager(layoutNodes)
+
     override val rootForTest: RootForTest = this
 
     override val semanticsOwner: SemanticsOwner =
@@ -585,7 +509,20 @@
 
     private val _autofill = if (autofillSupported()) AndroidAutofill(this, autofillTree) else null
 
-    internal val _autofillManager = if (autofillSupported()) AndroidAutofillManager(this) else null
+    internal val _autofillManager =
+        if (autofillSupported()) {
+            val platformAutofill = context.getSystemService(PlatformAndroidManager::class.java)
+            checkPreconditionNotNull(platformAutofill) { "Autofill service could not be located." }
+            AndroidAutofillManager(
+                platformAutofillManager = PlatformAutofillManagerImpl(platformAutofill),
+                semanticsOwner = semanticsOwner,
+                view = this,
+                rectManager = rectManager,
+                packageName = context.packageName
+            )
+        } else {
+            null
+        }
 
     // Used as a CompositionLocal for performing autofill.
     override val autofill: Autofill?
@@ -706,6 +643,7 @@
      * The legacy text input service. This is only used for new text input sessions if
      * [textInputSessionMutex] is null.
      */
+    @Deprecated("Use PlatformTextInputModifierNode instead.")
     override val textInputService =
         TextInputService(platformTextInputServiceInterceptor(legacyTextInputServiceAndroid))
 
@@ -898,7 +836,6 @@
 
     init {
         addOnAttachStateChangeListener(contentCaptureManager)
-        _autofillManager?.let { addOnAttachStateChangeListener(it) }
         setWillNotDraw(false)
         isFocusable = true
         if (SDK_INT >= O) {
@@ -1014,73 +951,22 @@
     }
 
     override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean {
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (!ComposeUiFlags.isViewFocusFixEnabled) {
-            // This view is already focused.
-            if (isFocused) return true
-
-            // If the root has focus, it means a sub-view is focused,
-            // and is trying to move focus within itself.
-            if (focusOwner.rootState.hasFocus) {
-                return super.requestFocus(direction, previouslyFocusedRect)
-            }
-
-            val focusDirection = toFocusDirection(direction) ?: Enter
-            return focusOwner.focusSearch(
-                focusDirection = focusDirection,
-                focusedRect = previouslyFocusedRect?.toComposeRect()
-            ) {
-                it.requestFocus(focusDirection)
-            } ?: false
-        }
         // This view is already focused.
         if (isFocused) return true
 
-        // There is nothing focusable and we've looped around all Views back to this one, so
-        // we just return false to indicate that nothing can be focused.
-        if (processingRequestFocusForNextNonChildView) return false
-
-        val focusDirection = toFocusDirection(direction) ?: Enter
-
         // If the root has focus, it means a sub-view is focused,
         // and is trying to move focus within itself.
-        if (hasFocus() && onMoveFocusInChildren(focusDirection)) return true
-
-        var foundFocusable = false
-        val focusSearchResult =
-            focusOwner.focusSearch(
-                focusDirection = focusDirection,
-                focusedRect = previouslyFocusedRect?.toComposeRect()
-            ) {
-                foundFocusable = true
-                it.requestFocus(focusDirection)
-            }
-        if (focusSearchResult == null) {
-            return false // The focus search was canceled
-        }
-        if (focusSearchResult) {
-            return true // We found something to focus on
-        }
-        if (foundFocusable) {
-            return false // The requestFocus() from within the focusSearch was canceled
+        if (focusOwner.rootState.hasFocus) {
+            return super.requestFocus(direction, previouslyFocusedRect)
         }
 
-        // We advertised ourselves as focusable, but we aren't. Try to just move the focus to the
-        // next item.
-        val nextFocusedView = findNextNonChildView(direction)
-
-        // Can crash if we return false when we've advertised ourselves as focusable and we aren't
-        // b/369256395
-        if (nextFocusedView == null || nextFocusedView === this) {
-            // There is no next View, so just return true so we don't cause a crash
-            return true
-        }
-
-        // Try to focus on the next focusable View
-        processingRequestFocusForNextNonChildView = true
-        val requestFocusResult = nextFocusedView.requestFocus(direction)
-        processingRequestFocusForNextNonChildView = false
-        return requestFocusResult
+        val focusDirection = toFocusDirection(direction) ?: Enter
+        return focusOwner.focusSearch(
+            focusDirection = focusDirection,
+            focusedRect = previouslyFocusedRect?.toComposeRect()
+        ) {
+            it.requestFocus(focusDirection)
+        } ?: false
     }
 
     private fun onRequestFocusForOwner(
@@ -1098,12 +984,7 @@
     }
 
     private fun onClearFocusForOwner() {
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (isFocused || (!ComposeUiFlags.isViewFocusFixEnabled && hasFocus())) {
-            super.clearFocus()
-        } else if (hasFocus()) {
-            // Call clearFocus() on the child that has focus
-            findFocus()?.clearFocus()
+        if (isFocused || hasFocus()) {
             super.clearFocus()
         }
     }
@@ -1195,7 +1076,10 @@
     }
 
     override fun onPostAttach(node: LayoutNode) {
-        // TODO(MNUZEN): add autofill logic here
+        @OptIn(ExperimentalComposeUiApi::class)
+        if (autofillSupported() && ComposeUiFlags.isSemanticAutofillEnabled) {
+            _autofillManager?.onPostAttach(node)
+        }
     }
 
     override fun onDetach(node: LayoutNode) {
@@ -1206,6 +1090,10 @@
         if (ComposeUiFlags.isRectTrackingEnabled) {
             rectManager.remove(node)
         }
+        @OptIn(ExperimentalComposeUiApi::class)
+        if (autofillSupported() && ComposeUiFlags.isSemanticAutofillEnabled) {
+            _autofillManager?.onDetach(node)
+        }
     }
 
     fun requestClearInvalidObservations() {
@@ -1221,6 +1109,10 @@
         if (childAndroidViews != null) {
             clearChildInvalidObservations(childAndroidViews)
         }
+        @OptIn(ExperimentalComposeUiApi::class)
+        if (autofillSupported() && ComposeUiFlags.isSemanticAutofillEnabled) {
+            _autofillManager?.onEndApplyChanges()
+        }
         // Listeners can add more items to the list and we want to ensure that they
         // are executed after being added, so loop until the list is empty
         while (endApplyChangesListeners.isNotEmpty() && endApplyChangesListeners[0] != null) {
@@ -1759,10 +1651,6 @@
     override fun onSemanticsChange() {
         composeAccessibilityDelegate.onSemanticsChange()
         contentCaptureManager.onSemanticsChange()
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (SDK_INT >= 26 && isSemanticAutofillEnabled) {
-            _autofillManager?.onSemanticsChange()
-        }
     }
 
     override fun onLayoutChange(layoutNode: LayoutNode) {
@@ -1770,8 +1658,6 @@
         contentCaptureManager.onLayoutChange(layoutNode)
     }
 
-    override val rectManager = RectManager(layoutNodes)
-
     override fun onLayoutNodeDeactivated(layoutNode: LayoutNode) {
         @OptIn(ExperimentalComposeUiApi::class)
         if (ComposeUiFlags.isRectTrackingEnabled) {
@@ -1986,9 +1872,8 @@
         viewTreeObserver.addOnScrollChangedListener(scrollChangedListener)
         viewTreeObserver.addOnTouchModeChangeListener(touchModeChangeListener)
 
-        if (SDK_INT >= S) {
-            AndroidComposeViewTranslationCallbackS.setViewTranslationCallback(this)
-        }
+        if (SDK_INT >= S) AndroidComposeViewTranslationCallbackS.setViewTranslationCallback(this)
+        if (autofillSupported()) _autofillManager?.let { semanticsOwner.listeners += it }
     }
 
     override fun onDetachedFromWindow() {
@@ -2013,25 +1898,26 @@
         viewTreeObserver.removeOnTouchModeChangeListener(touchModeChangeListener)
 
         if (SDK_INT >= S) AndroidComposeViewTranslationCallbackS.clearViewTranslationCallback(this)
+        if (autofillSupported()) _autofillManager?.let { semanticsOwner.listeners -= it }
     }
 
-    @OptIn(ExperimentalComposeUiApi::class)
     override fun onProvideAutofillVirtualStructure(structure: ViewStructure?, flags: Int) {
         if (autofillSupported() && structure != null) {
-            if (isSemanticAutofillEnabled) {
+            if (@OptIn(ExperimentalComposeUiApi::class) ComposeUiFlags.isSemanticAutofillEnabled) {
                 _autofillManager?.populateViewStructure(structure)
             } else {
+                // TODO(b/383201236): Remove _autofill and route requests through _autofillManager.
                 _autofill?.populateViewStructure(structure)
             }
         }
     }
 
-    @OptIn(ExperimentalComposeUiApi::class)
     override fun autofill(values: SparseArray<AutofillValue>) {
         if (autofillSupported()) {
-            if (isSemanticAutofillEnabled) {
+            if (@OptIn(ExperimentalComposeUiApi::class) ComposeUiFlags.isSemanticAutofillEnabled) {
                 _autofillManager?.performAutofill(values)
             } else {
+                // TODO(b/383201236): Remove _autofill and route requests through _autofillManager.
                 _autofill?.performAutofill(values)
             }
         }
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/FocusFinderCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/FocusFinderCompat.android.kt
deleted file mode 100644
index 9a42b02..0000000
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/FocusFinderCompat.android.kt
+++ /dev/null
@@ -1,462 +0,0 @@
-/*
- * Copyright 2024 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 androidx.compose.ui.platform
-
-import android.annotation.SuppressLint
-import android.content.pm.PackageManager
-import android.graphics.Rect
-import android.os.Build
-import android.view.View
-import android.view.View.FOCUS_BACKWARD
-import android.view.View.FOCUS_FORWARD
-import android.view.ViewGroup
-import androidx.collection.MutableObjectList
-import androidx.collection.ObjectList
-import androidx.collection.mutableObjectIntMapOf
-import androidx.collection.mutableScatterMapOf
-import androidx.collection.mutableScatterSetOf
-import androidx.core.view.isVisible
-import java.util.Collections
-
-/**
- * On devices before [Build.VERSION_CODES.O], [FocusFinder] orders Views incorrectly. This
- * implementation uses the current [FocusFinder] behavior, ordering Views correctly for
- * one-dimensional focus searches.
- *
- * This is copied and simplified from FocusFinder's source. There may be some code that doesn't look
- * quite right in Kotlin as it was copy/pasted with auto-translation.
- */
-internal class FocusFinderCompat {
-    companion object {
-        private val FocusFinderThreadLocal =
-            object : ThreadLocal<FocusFinderCompat>() {
-                override fun initialValue(): FocusFinderCompat {
-                    return FocusFinderCompat()
-                }
-            }
-
-        /** Get the focus finder for this thread. */
-        val instance: FocusFinderCompat
-            get() = FocusFinderThreadLocal.get()!!
-    }
-
-    private val focusedRect: Rect = Rect()
-
-    private val userSpecifiedFocusComparator =
-        UserSpecifiedFocusComparator({ r, v ->
-            if (isValidId(v.nextFocusForwardId)) v.findUserSetNextFocus(r, FOCUS_FORWARD) else null
-        })
-
-    private val tmpList = MutableObjectList<View>()
-
-    // enforce thread local access
-    private fun FocusFinder() {}
-
-    /**
-     * Find the next view to take focus in root's descendants, starting from the view that currently
-     * is focused.
-     *
-     * @param root Contains focused. Cannot be null.
-     * @param focused Has focus now.
-     * @param direction Direction to look.
-     * @return The next focusable view, or null if none exists.
-     */
-    fun findNextFocus1d(root: ViewGroup, focused: View, direction: Int): View? {
-        val effectiveRoot = getEffectiveRoot(root, focused)
-        var next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction)
-        if (next != null) {
-            return next
-        }
-        val focusables = tmpList
-        try {
-            focusables.clear()
-            effectiveRoot.addFocusableViews(focusables, direction)
-            if (!focusables.isEmpty()) {
-                next = findNextFocus(effectiveRoot, focused, direction, focusables)
-            }
-        } finally {
-            focusables.clear()
-        }
-        return next
-    }
-
-    /**
-     * Returns the "effective" root of a view. The "effective" root is the closest ancestor
-     * within-which focus should cycle.
-     *
-     * For example: normal focus navigation would stay within a ViewGroup marked as
-     * touchscreenBlocksFocus and keyboardNavigationCluster until a cluster-jump out.
-     *
-     * @return the "effective" root of {@param focused}
-     */
-    private fun getEffectiveRoot(root: ViewGroup, focused: View?): ViewGroup {
-        if (focused == null || focused === root) {
-            return root
-        }
-        var effective: ViewGroup? = null
-        var nextParent = focused.parent
-        while (nextParent is ViewGroup) {
-            if (nextParent === root) {
-                return effective ?: root
-            }
-            val vg = nextParent
-            if (
-                vg.touchscreenBlocksFocus &&
-                    focused.context.packageManager.hasSystemFeature(
-                        PackageManager.FEATURE_TOUCHSCREEN
-                    )
-            ) {
-                // Don't stop and return here because the cluster could be nested and we only
-                // care about the top-most one.
-                effective = vg
-            }
-            nextParent = nextParent.parent
-        }
-        return root
-    }
-
-    private fun findNextUserSpecifiedFocus(root: ViewGroup, focused: View, direction: Int): View? {
-        // check for user specified next focus
-        var userSetNextFocus: View? = focused.findUserSetNextFocus(root, direction)
-        var cycleCheck = userSetNextFocus
-        var cycleStep = true // we want the first toggle to yield false
-        while (userSetNextFocus != null) {
-            if (
-                userSetNextFocus.isFocusable &&
-                    userSetNextFocus.visibility == View.VISIBLE &&
-                    (!userSetNextFocus.isInTouchMode || userSetNextFocus.isFocusableInTouchMode)
-            ) {
-                return userSetNextFocus
-            }
-            userSetNextFocus = userSetNextFocus.findUserSetNextFocus(root, direction)
-            if ((!cycleStep).also { cycleStep = it }) {
-                cycleCheck = cycleCheck?.findUserSetNextFocus(root, direction)
-                if (cycleCheck === userSetNextFocus) {
-                    // found a cycle, user-specified focus forms a loop and none of the views
-                    // are currently focusable.
-                    break
-                }
-            }
-        }
-        return null
-    }
-
-    private fun findNextFocus(
-        root: ViewGroup,
-        focused: View,
-        direction: Int,
-        focusables: MutableObjectList<View>
-    ): View? {
-        val focusedRect = focusedRect
-        // fill in interesting rect from focused
-        focused.getFocusedRect(focusedRect)
-        root.offsetDescendantRectToMyCoords(focused, focusedRect)
-
-        return findNextFocusInRelativeDirection(focusables, root, focused, direction)
-    }
-
-    @SuppressLint("AsCollectionCall")
-    private fun findNextFocusInRelativeDirection(
-        focusables: MutableObjectList<View>,
-        root: ViewGroup?,
-        focused: View,
-        direction: Int
-    ): View? {
-        try {
-            // Note: This sort is stable.
-            userSpecifiedFocusComparator.setFocusables(focusables, root!!)
-            Collections.sort(focusables.asMutableList(), userSpecifiedFocusComparator)
-        } finally {
-            userSpecifiedFocusComparator.recycle()
-        }
-
-        val count = focusables.size
-        if (count < 2) {
-            return null
-        }
-        var next: View? = null
-        val looped = BooleanArray(1)
-        when (direction) {
-            FOCUS_FORWARD -> next = getNextFocusable(focused, focusables, count, looped)
-            FOCUS_BACKWARD -> next = getPreviousFocusable(focused, focusables, count, looped)
-        }
-        return next ?: focusables[count - 1]
-    }
-
-    private fun getNextFocusable(
-        focused: View,
-        focusables: ObjectList<View>,
-        count: Int,
-        outLooped: BooleanArray
-    ): View? {
-        if (count < 2) {
-            return null
-        }
-        val position = focusables.lastIndexOf(focused)
-        if (position >= 0 && position + 1 < count) {
-            return focusables[position + 1]
-        }
-        outLooped[0] = true
-        return focusables[0]
-    }
-
-    private fun getPreviousFocusable(
-        focused: View?,
-        focusables: ObjectList<View>,
-        count: Int,
-        outLooped: BooleanArray
-    ): View? {
-        if (count < 2) {
-            return null
-        }
-        if (focused != null) {
-            val position = focusables.indexOf(focused)
-            if (position > 0) {
-                return focusables[position - 1]
-            }
-        }
-        outLooped[0] = true
-        return focusables[count - 1]
-    }
-
-    private fun isValidId(id: Int): Boolean {
-        return id != 0 && id != View.NO_ID
-    }
-
-    /**
-     * Sorts views according to any explicitly-specified focus-chains. If there are no explicitly
-     * specified focus chains (eg. no nextFocusForward attributes defined), this should be a no-op.
-     */
-    private class UserSpecifiedFocusComparator(private val mNextFocusGetter: NextFocusGetter) :
-        Comparator<View?> {
-        private val nextFoci = mutableScatterMapOf<View, View>()
-        private val isConnectedTo = mutableScatterSetOf<View>()
-        private val headsOfChains = mutableScatterMapOf<View, View>()
-        private val originalOrdinal = mutableObjectIntMapOf<View>()
-        private var root: View? = null
-
-        fun interface NextFocusGetter {
-            fun get(root: View, view: View): View?
-        }
-
-        fun recycle() {
-            root = null
-            headsOfChains.clear()
-            isConnectedTo.clear()
-            originalOrdinal.clear()
-            nextFoci.clear()
-        }
-
-        fun setFocusables(focusables: ObjectList<View>, root: View) {
-            this.root = root
-            focusables.forEachIndexed { index, view -> originalOrdinal[view] = index }
-
-            for (i in focusables.indices.reversed()) {
-                val view = focusables[i]
-                val next = mNextFocusGetter.get(root, view)
-                if (next != null && originalOrdinal.containsKey(next)) {
-                    nextFoci[view] = next
-                    isConnectedTo.add(next)
-                }
-            }
-
-            for (i in focusables.indices.reversed()) {
-                val view = focusables[i]
-                val next = nextFoci[view]
-                if (next != null && !isConnectedTo.contains(view)) {
-                    setHeadOfChain(view)
-                }
-            }
-        }
-
-        fun setHeadOfChain(head: View) {
-            var newHead = head
-            var view: View? = newHead
-            while (view != null) {
-                val otherHead = headsOfChains[view]
-                if (otherHead != null) {
-                    if (otherHead === newHead) {
-                        return // This view has already had its head set properly
-                    }
-                    // A hydra -- multi-headed focus chain (e.g. A->C and B->C)
-                    // Use the one we've already chosen instead and reset this chain.
-                    view = newHead
-                    newHead = otherHead
-                }
-                headsOfChains[view] = newHead
-                view = nextFoci[view]
-            }
-        }
-
-        override fun compare(first: View?, second: View?): Int {
-            if (first === second) {
-                return 0
-            }
-            if (first == null) {
-                return -1
-            }
-            if (second == null) {
-                return 1
-            }
-            // Order between views within a chain is immaterial -- next/previous is
-            // within a chain is handled elsewhere.
-            val firstHead = headsOfChains[first]
-            val secondHead = headsOfChains[second]
-            if (firstHead === secondHead && firstHead != null) {
-                return if (first === firstHead) {
-                    -1 // first is the head, it should be first
-                } else if (second === firstHead) {
-                    1 // second is the head, it should be first
-                } else if (nextFoci[first] != null) {
-                    -1 // first is not the end of the chain
-                } else {
-                    1 // first is end of chain
-                }
-            }
-            val chainedFirst = firstHead ?: first
-            val chainedSecond = secondHead ?: second
-
-            return if (firstHead != null || secondHead != null) {
-                // keep original order between chains
-                if (originalOrdinal[chainedFirst] < originalOrdinal[chainedSecond]) -1 else 1
-            } else {
-                0
-            }
-        }
-    }
-}
-
-/**
- * If a user manually specified the next view id for a particular direction, use the root to look up
- * the view.
- *
- * @param root The root view of the hierarchy containing this view.
- * @param direction One of FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT, FOCUS_FORWARD, or
- *   FOCUS_BACKWARD.
- * @return The user specified next view, or null if there is none.
- */
-private fun View.findUserSetNextFocus(root: View, direction: Int): View? {
-    when (direction) {
-        FOCUS_FORWARD -> {
-            val next = nextFocusForwardId
-            if (next == View.NO_ID) return null
-            return findViewInsideOutShouldExist(root, this, next)
-        }
-        FOCUS_BACKWARD -> {
-            if (id == View.NO_ID) return null
-            val startView: View = this
-            // Since we have forward links but no backward links, we need to find the view that
-            // forward links to this view. We can't just find the view with the specified ID
-            // because view IDs need not be unique throughout the tree.
-            return root.findViewByPredicateInsideOut(startView) { t ->
-                (findViewInsideOutShouldExist(root, t, t.nextFocusForwardId) === startView)
-            }
-        }
-    }
-    return null
-}
-
-private fun findViewInsideOutShouldExist(root: View, start: View, id: Int): View? {
-    return root.findViewByPredicateInsideOut(start) { it.id == id }
-}
-
-/**
- * Look for a child view that matches the specified predicate, starting with the specified view and
- * its descendants and then recursively searching the ancestors and siblings of that view until this
- * view is reached.
- *
- * This method is useful in cases where the predicate does not match a single unique view (perhaps
- * multiple views use the same id) and we are trying to find the view that is "closest" in scope to
- * the starting view.
- *
- * @param start The view to start from.
- * @param predicate The predicate to evaluate.
- * @return The first view that matches the predicate or null.
- */
-private fun View.findViewByPredicateInsideOut(start: View, predicate: (View) -> Boolean): View? {
-    var next = start
-    var childToSkip: View? = null
-    while (true) {
-        val view = next.findViewByPredicateTraversal(predicate, childToSkip)
-        if (view != null || next === this) {
-            return view
-        }
-
-        val parent = next.parent
-        if (parent == null || parent !is View) {
-            return null
-        }
-
-        childToSkip = next
-        next = parent
-    }
-}
-
-/**
- * @param predicate The predicate to evaluate.
- * @param childToSkip If not null, ignores this child during the recursive traversal.
- * @return The first view that matches the predicate or null.
- */
-private fun View.findViewByPredicateTraversal(
-    predicate: (View) -> Boolean,
-    childToSkip: View?
-): View? {
-    if (predicate(this)) {
-        return this
-    }
-    if (this is ViewGroup) {
-        for (i in 0 until childCount) {
-            val child = getChildAt(i)
-            if (child !== childToSkip) {
-                val v = child.findViewByPredicateTraversal(predicate, childToSkip)
-                if (v != null) {
-                    return v
-                }
-            }
-        }
-    }
-    return null
-}
-
-private fun View.addFocusableViews(views: MutableObjectList<View>, direction: Int) {
-    addFocusableViews(views, direction, isInTouchMode)
-}
-
-/**
- * Older versions of View don't add focusable Views in order. This is a corrected version that adds
- * them in the right order.
- */
-private fun View.addFocusableViews(
-    views: MutableObjectList<View>,
-    direction: Int,
-    inTouchMode: Boolean
-) {
-    if (
-        isVisible &&
-            isFocusable &&
-            isEnabled &&
-            width > 0 &&
-            height > 0 &&
-            (!inTouchMode || isFocusableInTouchMode)
-    ) {
-        views += this
-    }
-    if (this is ViewGroup) {
-        for (i in 0 until childCount) {
-            getChildAt(i).addFocusableViews(views, direction, inTouchMode)
-        }
-    }
-}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/SemanticsUtils.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/SemanticsUtils.android.kt
index 27e6655..e715695 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/SemanticsUtils.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/SemanticsUtils.android.kt
@@ -49,8 +49,6 @@
     currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>
 ) {
     val unmergedConfig = semanticsNode.unmergedConfig
-    // Root node must always be considered visible and sent to listening services
-    val isTransparent = if (semanticsNode.isRoot) false else semanticsNode.isTransparent
     val children: MutableIntSet = MutableIntSet(semanticsNode.replacedChildren.size)
 
     init {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/AndroidViewHolder.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/AndroidViewHolder.android.kt
index 9c9835e..8b1d1dc9 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/AndroidViewHolder.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/AndroidViewHolder.android.kt
@@ -25,8 +25,6 @@
 import android.view.ViewParent
 import androidx.compose.runtime.ComposeNodeLifecycleCallback
 import androidx.compose.runtime.CompositionContext
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawBehind
@@ -420,10 +418,6 @@
             if (view.parent !== this) addView(view)
         }
         layoutNode.onDetach = { owner ->
-            @OptIn(ExperimentalComposeUiApi::class)
-            if (ComposeUiFlags.isViewFocusFixEnabled && hasFocus()) {
-                owner.focusOwner.clearFocus(true)
-            }
             (owner as? AndroidComposeView)?.removeAndroidView(this)
             removeAllViewsInLayout()
         }
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt
index 8082b9a..0d25269 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/viewinterop/FocusGroupNode.android.kt
@@ -22,8 +22,6 @@
 import android.view.ViewGroup
 import android.view.ViewGroup.FOCUS_DOWN
 import android.view.ViewTreeObserver
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusDirection.Companion.Exit
@@ -88,12 +86,7 @@
 
     val onExit: FocusEnterExitScope.() -> Unit = {
         val embeddedView = getEmbeddedView()
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (ComposeUiFlags.isViewFocusFixEnabled) {
-            if (embeddedView.hasFocus() || embeddedView.isFocused) {
-                embeddedView.clearFocus()
-            }
-        } else if (embeddedView.hasFocus()) {
+        if (embeddedView.hasFocus()) {
             val focusOwner = requireOwner().focusOwner
             val hostView = requireView()
 
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt
index ed03be2..fb323fe2 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/spatial/ThrottledCallbacksTest.kt
@@ -223,7 +223,7 @@
         id: Int,
         throttleMs: Long,
         debounceMs: Long,
-        callback: (RectInfo) -> Unit
+        callback: (RelativeLayoutBounds) -> Unit
     ): DisposableHandle {
         return registerOnRectChanged(id, throttleMs, debounceMs, fakeNode(), callback)
     }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt
index d518673..728a1bb 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/ComposeUiFlags.kt
@@ -80,10 +80,4 @@
      * the new Autofill APIs and features introduced.
      */
     @Suppress("MutableBareField") @JvmField var isSemanticAutofillEnabled: Boolean = false
-
-    /**
-     * This enables fixes for View focus. The changes are large enough to require a flag to allow
-     * disabling them.
-     */
-    @Suppress("MutableBareField") @JvmField var isViewFocusFixEnabled: Boolean = true
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt
index 504d86a..bbfbafe 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillType.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.ui.autofill
 
+// TODO(b/383201236): This is replaced by ContentType. It was experimental in 1.7 so mark it
+//  deprecated before 1.8.
 /**
  * Autofill type information.
  *
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
index d86a6bc..8741918 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
@@ -17,8 +17,6 @@
 package androidx.compose.ui.focus
 
 import androidx.collection.MutableLongSet
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.CustomDestinationResult.Cancelled
 import androidx.compose.ui.focus.CustomDestinationResult.None
@@ -204,11 +202,6 @@
      * @return true if focus was moved successfully. false if the focused item is unchanged.
      */
     override fun moveFocus(focusDirection: FocusDirection): Boolean {
-        // First check to see if the focus should move within child Views
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (ComposeUiFlags.isViewFocusFixEnabled && onMoveFocusInterop(focusDirection)) {
-            return true
-        }
         var requestFocusSuccess: Boolean? = false
         val generationBefore = focusTransactionManager.generation
         val focusSearchSuccess =
@@ -244,8 +237,7 @@
         // If we couldn't move focus within compose, we attempt to move focus within embedded views.
         // We don't need this for 1D focus search because the wrap-around logic triggers a
         // focus exit which will perform a focus search among the subviews.
-        @OptIn(ExperimentalComposeUiApi::class)
-        return !ComposeUiFlags.isViewFocusFixEnabled && onMoveFocusInterop(focusDirection)
+        return onMoveFocusInterop(focusDirection)
     }
 
     override fun focusSearch(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
index fe97805..7d1b2d6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTransactions.kt
@@ -16,9 +16,6 @@
 
 package androidx.compose.ui.focus
 
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.focus.CustomDestinationResult.Cancelled
 import androidx.compose.ui.focus.CustomDestinationResult.None
 import androidx.compose.ui.focus.CustomDestinationResult.RedirectCancelled
@@ -32,7 +29,6 @@
 import androidx.compose.ui.node.Nodes.FocusTarget
 import androidx.compose.ui.node.nearestAncestor
 import androidx.compose.ui.node.observeReads
-import androidx.compose.ui.node.requireLayoutNode
 import androidx.compose.ui.node.requireOwner
 
 /**
@@ -62,14 +58,7 @@
                 }
             }
         }
-    if (success) {
-        @OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class)
-        if (ComposeUiFlags.isViewFocusFixEnabled && requireLayoutNode().getInteropView() == null) {
-            // This isn't an AndroidView, so we should be focused on this ComposeView
-            requireOwner().focusOwner.requestFocusForOwner(FocusDirection.Next, null)
-        }
-        dispatchFocusCallbacks()
-    }
+    if (success) dispatchFocusCallbacks()
     return success
 }
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListener.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListener.kt
index 55a4457..e65c8c9 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListener.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnGlobalLayoutListener.kt
@@ -19,52 +19,52 @@
 import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.requireLayoutNode
 import androidx.compose.ui.node.requireOwner
-import androidx.compose.ui.spatial.RectInfo
+import androidx.compose.ui.spatial.RelativeLayoutBounds
 import kotlinx.coroutines.DisposableHandle
 
 /**
  * Registers a [callback] to be executed with the position of this modifier node relative to the
  * coordinate system of the root of the composition, as well as in screen coordinates and window
- * coordinates, see [RectInfo].
+ * coordinates, see [RelativeLayoutBounds].
  *
  * It may also be used to calculate certain Layout relationships at the time of the callback
- * execution, such as [RectInfo.calculateOcclusions].
+ * execution, such as [RelativeLayoutBounds.calculateOcclusions].
  *
  * This will be called after layout pass. This API allows for throttling and debouncing parameters
  * in order to moderate the frequency with which the callback gets invoked during high rates of
  * change (e.g. scrolling).
  *
- * Specifying [throttleMs] will prevent [callback] from being executed more than once over that time
- * period. Specifying [debounceMs] will delay the execution of [callback] until that amount of time
- * has elapsed without a new position.
+ * Specifying [throttleMillis] will prevent [callback] from being executed more than once over that
+ * time period. Specifying [debounceMillis] will delay the execution of [callback] until that amount
+ * of time has elapsed without a new position.
  *
- * Specifying 0 for both [throttleMs] and [debounceMs] will result in the callback being executed
- * every time the position has changed. Specifying non-zero amounts for both will result in both
- * conditions being met.
+ * Specifying 0 for both [throttleMillis] and [debounceMillis] will result in the callback being
+ * executed every time the position has changed. Specifying non-zero amounts for both will result in
+ * both conditions being met.
  *
- * @param throttleMs The duration, in milliseconds, to prevent [callback] from being executed more
- *   than once over that time period.
- * @param debounceMs The duration, in milliseconds, to delay the execution of [callback] until that
- *   amount of time has elapsed without a new position.
- * @param callback The callback to be executed, provides a new [RectInfo] instance associated to
- *   this [DelegatableNode]. Keep in mind this callback is executed on the main thread even when
- *   debounced.
+ * @param throttleMillis The duration, in milliseconds, to prevent [callback] from being executed
+ *   more than once over that time period.
+ * @param debounceMillis The duration, in milliseconds, to delay the execution of [callback] until
+ *   that amount of time has elapsed without a new position.
+ * @param callback The callback to be executed, provides a new [RelativeLayoutBounds] instance
+ *   associated to this [DelegatableNode]. Keep in mind this callback is executed on the main thread
+ *   even when debounced.
  * @return an object which should be used to unregister/dispose this callback, such as when a node
  *   is detached
  */
 @Suppress("PairedRegistration") // User expected to handle disposing
 fun DelegatableNode.registerOnGlobalLayoutListener(
-    throttleMs: Int,
-    debounceMs: Int,
-    callback: (RectInfo) -> Unit
+    throttleMillis: Long,
+    debounceMillis: Long,
+    callback: (RelativeLayoutBounds) -> Unit
 ): DisposableHandle {
     val layoutNode = requireLayoutNode()
     val id = layoutNode.semanticsId
     val rectManager = layoutNode.requireOwner().rectManager
     return rectManager.registerOnGlobalLayoutCallback(
         id = id,
-        throttleMs = throttleMs,
-        debounceMs = debounceMs,
+        throttleMillis = throttleMillis,
+        debounceMillis = debounceMillis,
         node = node,
         callback = callback
     )
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnLayoutRectChangedModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnLayoutRectChangedModifier.kt
new file mode 100644
index 0000000..37a7f0f
--- /dev/null
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnLayoutRectChangedModifier.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.layout
+
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.requireLayoutNode
+import androidx.compose.ui.node.requireOwner
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.spatial.RelativeLayoutBounds
+import kotlinx.coroutines.DisposableHandle
+
+/**
+ * Invokes [callback] with the position of this layout node relative to the coordinate system of the
+ * root of the composition, as well as in screen coordinates and window coordinates. This will be
+ * called after layout pass. This API allows for throttling and debouncing parameters in order to
+ * moderate the frequency with which the callback gets invoked during high rates of change (e.g.
+ * scrolling).
+ *
+ * Specifying [throttleMillis] will prevent [callback] from being executed more than once over that
+ * time period. Specifying [debounceMillis] will delay the execution of [callback] until that amount
+ * of time has elapsed without a new position, scheduling the callback to be executed when that
+ * amount of time expires.
+ *
+ * Specifying 0 for both [throttleMillis] and [debounceMillis] will result in the callback being
+ * executed every time the position has changed. Specifying non-zero amounts for both will result in
+ * both conditions being met. Specifying a non-zero [throttleMillis] but a zero [debounceMillis] is
+ * equivalent to providing the same value for both [throttleMillis] and [debounceMillis].
+ *
+ * @param throttleMillis The duration, in milliseconds, to prevent [callback] from being executed
+ *   more than once over that time period.
+ * @param debounceMillis The duration, in milliseconds, to delay the execution of [callback] until
+ *   that amount of time has elapsed without a new position.
+ * @param callback The callback to be executed.
+ * @see RelativeLayoutBounds
+ * @see onGloballyPositioned
+ * @see registerOnLayoutRectChanged
+ */
+@Stable
+fun Modifier.onLayoutRectChanged(
+    throttleMillis: Long = 0,
+    debounceMillis: Long = 64,
+    callback: (RelativeLayoutBounds) -> Unit
+) = this then OnLayoutRectChangedElement(throttleMillis, debounceMillis, callback)
+
+private data class OnLayoutRectChangedElement(
+    val throttleMillis: Long,
+    val debounceMillis: Long,
+    val callback: (RelativeLayoutBounds) -> Unit
+) : ModifierNodeElement<OnLayoutRectChangedNode>() {
+    override fun create() = OnLayoutRectChangedNode(throttleMillis, debounceMillis, callback)
+
+    override fun update(node: OnLayoutRectChangedNode) {
+        node.throttleMillis = throttleMillis
+        node.debounceMillis = debounceMillis
+        node.callback = callback
+        node.disposeAndRegister()
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "onRectChanged"
+        properties["throttleMillis"] = throttleMillis
+        properties["debounceMillis"] = debounceMillis
+        properties["callback"] = callback
+    }
+}
+
+private class OnLayoutRectChangedNode(
+    var throttleMillis: Long,
+    var debounceMillis: Long,
+    var callback: (RelativeLayoutBounds) -> Unit,
+) : Modifier.Node() {
+    var handle: DisposableHandle? = null
+
+    fun disposeAndRegister() {
+        handle?.dispose()
+        handle = registerOnLayoutRectChanged(throttleMillis, debounceMillis, callback)
+    }
+
+    override fun onAttach() {
+        disposeAndRegister()
+    }
+
+    override fun onDetach() {
+        handle?.dispose()
+    }
+}
+
+/**
+ * Registers a [callback] to be executed with the position of this modifier node relative to the
+ * coordinate system of the root of the composition, as well as in screen coordinates and window
+ * coordinates. This will be called after layout pass. This API allows for throttling and debouncing
+ * parameters in order to moderate the frequency with which the callback gets invoked during high
+ * rates of change (e.g. scrolling).
+ *
+ * Specifying [throttleMillis] will prevent [callback] from being executed more than once over that
+ * time period. Specifying [debounceMillis] will delay the execution of [callback] until that amount
+ * of time has elapsed without a new position.
+ *
+ * Specifying 0 for both [throttleMillis] and [debounceMillis] will result in the callback being
+ * executed every time the position has changed. Specifying non-zero amounts for both will result in
+ * both conditions being met.
+ *
+ * @param throttleMillis The duration, in milliseconds, to prevent [callback] from being executed
+ *   more than once over that time period.
+ * @param debounceMillis The duration, in milliseconds, to delay the execution of [callback] until
+ *   that amount of time has elapsed without a new position.
+ * @param callback The callback to be executed.
+ * @return an object which should be used to unregister/dispose this callback
+ * @see onLayoutRectChanged
+ */
+fun DelegatableNode.registerOnLayoutRectChanged(
+    throttleMillis: Long,
+    debounceMillis: Long,
+    callback: (RelativeLayoutBounds) -> Unit,
+): DisposableHandle {
+    val layoutNode = requireLayoutNode()
+    val id = layoutNode.semanticsId
+    val rectManager = layoutNode.requireOwner().rectManager
+    return rectManager.registerOnRectChangedCallback(
+        id,
+        throttleMillis,
+        debounceMillis,
+        this,
+        callback,
+    )
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt
deleted file mode 100644
index 443b6ee..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/OnRectChangedModifier.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * Copyright 2024 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 androidx.compose.ui.layout
-
-import androidx.compose.runtime.Stable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.node.DelegatableNode
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.node.requireLayoutNode
-import androidx.compose.ui.node.requireOwner
-import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.spatial.RectInfo
-import kotlinx.coroutines.DisposableHandle
-
-/**
- * Invokes [callback] with the position of this layout node relative to the coordinate system of the
- * root of the composition, as well as in screen coordinates and window coordinates. This will be
- * called after layout pass. This API allows for throttling and debouncing parameters in order to
- * moderate the frequency with which the callback gets invoked during high rates of change (e.g.
- * scrolling).
- *
- * Specifying [throttleMs] will prevent [callback] from being executed more than once over that time
- * period. Specifying [debounceMs] will delay the execution of [callback] until that amount of time
- * has elapsed without a new position, scheduling the callback to be executed when that amount of
- * time expires.
- *
- * Specifying 0 for both [throttleMs] and [debounceMs] will result in the callback being executed
- * every time the position has changed. Specifying non-zero amounts for both will result in both
- * conditions being met. Specifying a non-zero [throttleMs] but a zero [debounceMs] is equivalent to
- * providing the same value for both [throttleMs] and [debounceMs].
- *
- * @param throttleMs The duration, in milliseconds, to prevent [callback] from being executed more
- *   than once over that time period.
- * @param debounceMs The duration, in milliseconds, to delay the execution of [callback] until that
- *   amount of time has elapsed without a new position.
- * @param callback The callback to be executed.
- * @see RectInfo
- * @see onGloballyPositioned
- * @see registerOnRectChanged
- */
-@Stable
-fun Modifier.onRectChanged(
-    throttleMs: Int = 0,
-    debounceMs: Int = 64,
-    callback: (RectInfo) -> Unit
-) = this then OnRectChangedElement(throttleMs, debounceMs, callback)
-
-private data class OnRectChangedElement(
-    val throttleMs: Int,
-    val debounceMs: Int,
-    val callback: (RectInfo) -> Unit
-) : ModifierNodeElement<OnRectChangedNode>() {
-    override fun create() = OnRectChangedNode(throttleMs, debounceMs, callback)
-
-    override fun update(node: OnRectChangedNode) {
-        node.throttleMs = throttleMs
-        node.debounceMs = debounceMs
-        node.callback = callback
-        node.disposeAndRegister()
-    }
-
-    override fun InspectorInfo.inspectableProperties() {
-        name = "onRectChanged"
-        properties["throttleMs"] = throttleMs
-        properties["debounceMs"] = debounceMs
-        properties["callback"] = callback
-    }
-}
-
-private class OnRectChangedNode(
-    var throttleMs: Int,
-    var debounceMs: Int,
-    var callback: (RectInfo) -> Unit,
-) : Modifier.Node() {
-    var handle: DisposableHandle? = null
-
-    fun disposeAndRegister() {
-        handle?.dispose()
-        handle = registerOnRectChanged(throttleMs, debounceMs, callback)
-    }
-
-    override fun onAttach() {
-        disposeAndRegister()
-    }
-
-    override fun onDetach() {
-        handle?.dispose()
-    }
-}
-
-/**
- * Registers a [callback] to be executed with the position of this modifier node relative to the
- * coordinate system of the root of the composition, as well as in screen coordinates and window
- * coordinates. This will be called after layout pass. This API allows for throttling and debouncing
- * parameters in order to moderate the frequency with which the callback gets invoked during high
- * rates of change (e.g. scrolling).
- *
- * Specifying [throttleMs] will prevent [callback] from being executed more than once over that time
- * period. Specifying [debounceMs] will delay the execution of [callback] until that amount of time
- * has elapsed without a new position.
- *
- * Specifying 0 for both [throttleMs] and [debounceMs] will result in the callback being executed
- * every time the position has changed. Specifying non-zero amounts for both will result in both
- * conditions being met.
- *
- * @param throttleMs The duration, in milliseconds, to prevent [callback] from being executed more
- *   than once over that time period.
- * @param debounceMs The duration, in milliseconds, to delay the execution of [callback] until that
- *   amount of time has elapsed without a new position.
- * @param callback The callback to be executed.
- * @return an object which should be used to unregister/dispose this callback
- * @see onRectChanged
- */
-fun DelegatableNode.registerOnRectChanged(
-    throttleMs: Int,
-    debounceMs: Int,
-    callback: (RectInfo) -> Unit,
-): DisposableHandle {
-    val layoutNode = requireLayoutNode()
-    val id = layoutNode.semanticsId
-    val rectManager = layoutNode.requireOwner().rectManager
-    return rectManager.registerOnRectChangedCallback(
-        id,
-        throttleMs,
-        debounceMs,
-        this,
-        callback,
-    )
-}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt
index 4c26b36..62c8c45 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/Placeable.kt
@@ -510,8 +510,9 @@
          */
         private fun Placeable.handleMotionFrameOfReferencePlacement() {
             if (this is MotionReferencePlacementDelegate) {
-                this.isPlacedUnderMotionFrameOfReference =
+                updatePlacedUnderMotionFrameOfReference(
                     [email protected]
+                )
             }
         }
     }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutAwareModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutAwareModifierNode.kt
index 8f32b3a..23b6f98 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutAwareModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutAwareModifierNode.kt
@@ -52,3 +52,8 @@
      */
     fun onRemeasured(size: IntSize) {}
 }
+
+// TODO(b/309776096): Make it public
+internal interface OnUnplacedModifierNode : DelegatableNode {
+    fun onUnplaced()
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index 623b582..bc99aece 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -398,6 +398,8 @@
         invalidateMeasurements()
     }
 
+    override fun isTransparent(): Boolean = outerCoordinator.isTransparent()
+
     private var isSemanticsInvalidated = false
 
     internal fun invalidateSemantics() {
@@ -622,10 +624,6 @@
             return _zSortedChildren
         }
 
-    @Suppress("UNCHECKED_CAST")
-    override val childrenInfo: MutableVector<SemanticsInfo>
-        get() = zSortedChildren as MutableVector<SemanticsInfo>
-
     override val isValidOwnerScope: Boolean
         get() = isAttached
 
@@ -1357,6 +1355,9 @@
     override val parentInfo: SemanticsInfo?
         get() = parent
 
+    override val childrenInfo: List<SemanticsInfo>
+        get() = children
+
     override var isDeactivated = false
         private set
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
index 667b10f..5b0f57d 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
@@ -536,6 +536,10 @@
             if (isPlaced) {
                 isPlaced = false
                 layoutNode.forEachCoordinatorIncludingInner {
+                    // TODO(b/309776096): Node can be detached without calling this, so we need to
+                    //  find a better place to more reliable call this.
+                    it.onUnplaced()
+
                     // nodes are not placed with a layer anymore, so the layers should be released
                     it.releaseLayer()
                 }
@@ -817,16 +821,17 @@
         private var needsCoordinatesUpdate = false
 
         override var isPlacedUnderMotionFrameOfReference: Boolean = false
-            set(new) {
-                // Delegated to outerCoordinator
-                val old = outerCoordinator.isPlacedUnderMotionFrameOfReference
-                if (new != old) {
-                    outerCoordinator.isPlacedUnderMotionFrameOfReference = old
-                    // Affects coordinates measurements
-                    this.needsCoordinatesUpdate = true
-                }
-                field = new
+
+        override fun updatePlacedUnderMotionFrameOfReference(newMFR: Boolean) {
+            // Delegated to outerCoordinator
+            val old = outerCoordinator.isPlacedUnderMotionFrameOfReference
+            if (newMFR != old) {
+                outerCoordinator.isPlacedUnderMotionFrameOfReference = newMFR
+                // Affects coordinates measurements
+                this.needsCoordinatesUpdate = true
             }
+            isPlacedUnderMotionFrameOfReference = newMFR
+        }
 
         private fun placeSelf(
             position: IntOffset,
@@ -939,21 +944,53 @@
         }
 
         override fun minIntrinsicWidth(height: Int): Int {
+            // If there is an intrinsic size query coming from above the lookahead root, we will
+            // direct the query down to the lookahead pass. Note, when a regular measure call
+            // reaches a top-level lookahead root, the measure call is turned into lookahead
+            // measure followed by approach measure. This is a similar, although not exactly the
+            // same, mental model.
+            if (layoutNode.isOutMostLookaheadRoot) {
+                return lookaheadPassDelegate!!.minIntrinsicWidth(height)
+            }
             onIntrinsicsQueried()
             return outerCoordinator.minIntrinsicWidth(height)
         }
 
         override fun maxIntrinsicWidth(height: Int): Int {
+            // If there is an intrinsic size query coming from above the lookahead root, we will
+            // direct the query down to the lookahead pass. Note, when a regular measure call
+            // reaches a top-level lookahead root, the measure call is turned into lookahead
+            // measure followed by approach measure. This is a similar, although not exactly the
+            // same, mental model.
+            if (layoutNode.isOutMostLookaheadRoot) {
+                return lookaheadPassDelegate!!.maxIntrinsicWidth(height)
+            }
             onIntrinsicsQueried()
             return outerCoordinator.maxIntrinsicWidth(height)
         }
 
         override fun minIntrinsicHeight(width: Int): Int {
+            // If there is an intrinsic size query coming from above the lookahead root, we will
+            // direct the query down to the lookahead pass. Note, when a regular measure call
+            // reaches a top-level lookahead root, the measure call is turned into lookahead
+            // measure followed by approach measure. This is a similar, although not exactly the
+            // same, mental model.
+            if (layoutNode.isOutMostLookaheadRoot) {
+                return lookaheadPassDelegate!!.minIntrinsicHeight(width)
+            }
             onIntrinsicsQueried()
             return outerCoordinator.minIntrinsicHeight(width)
         }
 
         override fun maxIntrinsicHeight(width: Int): Int {
+            // If there is an intrinsic size query coming from above the lookahead root, we will
+            // direct the query down to the lookahead pass. Note, when a regular measure call
+            // reaches a top-level lookahead root, the measure call is turned into lookahead
+            // measure followed by approach measure. This is a similar, although not exactly the
+            // same, mental model.
+            if (layoutNode.isOutMostLookaheadRoot) {
+                return lookaheadPassDelegate!!.maxIntrinsicHeight(width)
+            }
             onIntrinsicsQueried()
             return outerCoordinator.maxIntrinsicHeight(width)
         }
@@ -1536,14 +1573,15 @@
         }
 
         override var isPlacedUnderMotionFrameOfReference: Boolean = false
-            set(new) {
-                // Delegated to outerCoordinator
-                val old = outerCoordinator.lookaheadDelegate?.isPlacedUnderMotionFrameOfReference
-                if (new != old) {
-                    outerCoordinator.lookaheadDelegate?.isPlacedUnderMotionFrameOfReference = new
-                }
-                field = new
+
+        override fun updatePlacedUnderMotionFrameOfReference(newMFR: Boolean) {
+            // Delegated to outerCoordinator
+            val old = outerCoordinator.lookaheadDelegate?.isPlacedUnderMotionFrameOfReference
+            if (newMFR != old) {
+                outerCoordinator.lookaheadDelegate?.isPlacedUnderMotionFrameOfReference = newMFR
             }
+            isPlacedUnderMotionFrameOfReference = newMFR
+        }
 
         private fun placeSelf(
             position: IntOffset,
@@ -2050,20 +2088,33 @@
  * [LookaheadCapablePlaceable.isPlacedUnderMotionFrameOfReference] to the proper placeable.
  */
 internal interface MotionReferencePlacementDelegate {
-
     /**
      * Called when a layout is about to be placed.
      *
-     * The corresponding [LookaheadCapablePlaceable] should have their
-     * [LookaheadCapablePlaceable.isPlacedUnderMotionFrameOfReference] flag updated to the given
-     * value.
+     * This updates the corresponding [LookaheadCapablePlaceable]'s
+     * [LookaheadCapablePlaceable.isPlacedUnderMotionFrameOfReference] flag IF AND ONLY IF the
+     * placement call comes from parent [LookaheadCapablePlaceable]. More specifically, for
+     * [LookaheadCapablePlaceable] that are the head of the modifier chain (e.g. outerCoordinator),
+     * the placement call doesn't always come from the parent, as the node can be independently
+     * replaced. For these [LookaheadCapablePlaceable], we maintain the old
+     * [isPlacedUnderMotionFrameOfReference] until the next placement call comes from the parent.
+     * This reason is that only placement from parent runs the placement lambda where
+     * [Placeable.PlacementScope.withMotionFrameOfReferencePlacement] is invoked. Also note, for
+     * [LookaheadCapablePlaceable] that are not the head of the modifier chain, the placement call
+     * always comes from the parent.
      *
      * The placeable should be tagged such that its corresponding coordinates reflect the flag in
      * [androidx.compose.ui.layout.LayoutCoordinates.introducesMotionFrameOfReference]. Note that
      * when it's placed on the current frame of reference, it means it doesn't introduce a new frame
      * of reference.
-     *
-     * This also means that coordinates consumers (onPlaced readers) are expected to be updated.
      */
-    var isPlacedUnderMotionFrameOfReference: Boolean
+    fun updatePlacedUnderMotionFrameOfReference(newMFR: Boolean)
+
+    /**
+     * Flag to indicate whether the [MotionReferencePlacementDelegate] is being placed under motion
+     * frame of reference. This is also reflected in
+     * [androidx.compose.ui.layout.LayoutCoordinates.introducesMotionFrameOfReference], which is
+     * used for local position calculation between coordinates.
+     */
+    val isPlacedUnderMotionFrameOfReference: Boolean
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LookaheadDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LookaheadDelegate.kt
index 708fec5..e74274b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LookaheadDelegate.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LookaheadDelegate.kt
@@ -61,6 +61,24 @@
      */
     override var isPlacedUnderMotionFrameOfReference: Boolean = false
 
+    override fun updatePlacedUnderMotionFrameOfReference(newMFR: Boolean) {
+        val parentNode = parent?.layoutNode
+        if (parentNode == layoutNode) {
+            isPlacedUnderMotionFrameOfReference = newMFR
+        } else {
+            // This node is the beginning of the chain (i.e. outerCoordinator), check if this
+            // placement call comes from the parent
+            if (
+                parentNode?.layoutState == LayoutNode.LayoutState.LayingOut ||
+                    parentNode?.layoutState == LayoutNode.LayoutState.LookaheadLayingOut
+            ) {
+                isPlacedUnderMotionFrameOfReference = newMFR
+            }
+            // If the node is simply being replaced without parent, we need to maintain the flag
+            // from last time when `placeChildren` lambda was run. Therefore no op.
+        }
+    }
+
     val rulerScope: RulerScope
         get() {
             return _rulerScope
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
index f102b02..a59cb92 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
@@ -320,6 +320,12 @@
         }
     }
 
+    fun onUnplaced() {
+        if (hasNode(Nodes.Unplaced)) {
+            visitNodes(Nodes.Unplaced) { it.onUnplaced() }
+        }
+    }
+
     /** Places the modified child. */
     /*@CallSuper*/
     override fun placeAt(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
index ec063bb..d204355 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
@@ -147,6 +147,10 @@
     @JvmStatic
     inline val BringIntoView
         get() = NodeKind<BringIntoViewModifierNode>(0b1 shl 19)
+
+    @JvmStatic
+    inline val Unplaced
+        get() = NodeKind<OnUnplacedModifierNode>(0b1 shl 20)
     // ...
 }
 
@@ -253,6 +257,9 @@
         if (node is BringIntoViewModifierNode) {
             mask = mask or Nodes.BringIntoView
         }
+        if (node is OnUnplacedModifierNode) {
+            mask = mask or Nodes.Unplaced
+        }
         mask
     }
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
index d6849c4..5a471e9a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
@@ -16,10 +16,8 @@
 
 package androidx.compose.ui.semantics
 
-import androidx.compose.runtime.collection.MutableVector
+import androidx.collection.MutableObjectList
 import androidx.compose.ui.layout.LayoutInfo
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.node.LayoutNode
 
 /**
  * This is an internal interface that can be used by [SemanticsListener]s to read semantic
@@ -31,6 +29,9 @@
     /** The semantics configuration (Semantic properties and actions) associated with this node. */
     val semanticsConfiguration: SemanticsConfiguration?
 
+    /** Whether the node is transparent. */
+    fun isTransparent(): Boolean
+
     /**
      * The [SemanticsInfo] of the parent.
      *
@@ -39,13 +40,12 @@
     override val parentInfo: SemanticsInfo?
 
     /**
-     * Returns the children list sorted by their [LayoutNode.zIndex] first (smaller first) and the
-     * order they were placed via [Placeable.placeAt] by parent (smaller first). Please note that
-     * this list contains not placed items as well, so you have to manually filter them.
+     * Returns the list of children.
      *
-     * Note that the object is reused so you shouldn't save it for later.
+     * Please note that this list contains not placed items as well, so you have to manually filter
+     * them. Note that the object is reused so you shouldn't save it for later.
      */
-    val childrenInfo: MutableVector<SemanticsInfo>
+    val childrenInfo: List<SemanticsInfo>
 }
 
 /** The semantics parent (nearest ancestor which has semantic properties). */
@@ -68,18 +68,36 @@
     return null
 }
 
-internal inline fun SemanticsInfo.findSemanticsChildren(
-    includeDeactivated: Boolean = false,
-    block: (SemanticsInfo) -> Unit
-) {
-    val unvisitedStack = MutableVector<SemanticsInfo>(childrenInfo.size)
-    childrenInfo.forEachReversed { unvisitedStack += it }
-    while (unvisitedStack.isNotEmpty()) {
-        val child = unvisitedStack.removeAt(unvisitedStack.lastIndex)
-        when {
-            child.isDeactivated && !includeDeactivated -> continue
-            child.semanticsConfiguration != null -> block(child)
-            else -> child.childrenInfo.forEachReversed { unvisitedStack += it }
-        }
+/** Merges the semantics of all the children of this node into a single SemanticsConfiguration. */
+internal fun SemanticsInfo.mergedSemanticsConfiguration(): SemanticsConfiguration? {
+    val unMergedConfig = semanticsConfiguration
+    if (
+        unMergedConfig == null ||
+            !unMergedConfig.isMergingSemanticsOfDescendants ||
+            unMergedConfig.isClearingSemantics
+    ) {
+        return unMergedConfig
     }
+
+    var mergedConfig: SemanticsConfiguration = unMergedConfig.copy()
+    val needsMerging: MutableObjectList<SemanticsInfo> =
+        MutableObjectList<SemanticsInfo>(childrenInfo.size).apply { addAll(childrenInfo) }
+
+    @Suppress("Range") // isNotEmpty ensures removeAt is not called with -1.
+    while (needsMerging.isNotEmpty()) {
+        val childInfo = needsMerging.removeAt(needsMerging.lastIndex)
+        val childConfig = childInfo.semanticsConfiguration
+
+        // Don't merge children that themselves merge all their descendants (because that
+        // indicates they are independently screen-reader-focusable).
+        if (childConfig == null || childConfig.isMergingSemanticsOfDescendants) continue
+
+        // Merge child values.
+        mergedConfig.mergeChild(childConfig)
+
+        // Merge children (unless this child is clearing semantics).
+        if (!childConfig.isClearingSemantics) needsMerging.addAll(childInfo.childrenInfo)
+    }
+
+    return mergedConfig
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
index 84ae0fc..babb0be 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
@@ -216,7 +216,11 @@
         get() {
             if (isMergingSemanticsOfDescendants) {
                 val mergedConfig = unmergedConfig.copy()
-                mergeConfig(mutableListOf(), mergedConfig)
+                mergeConfig(
+                    // TODO(b/384549982): Pass in the unmerged children instead of an empty list.
+                    mutableListOf(),
+                    mergedConfig
+                )
                 return mergedConfig
             } else {
                 return unmergedConfig
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt
deleted file mode 100644
index 723389a..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectInfo.kt
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- * Copyright 2024 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 androidx.compose.ui.spatial
-
-import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.graphics.Matrix
-import androidx.compose.ui.node.DelegatableNode
-import androidx.compose.ui.node.requireLayoutNode
-import androidx.compose.ui.node.requireOwner
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.IntRect
-import androidx.compose.ui.unit.roundToIntRect
-
-/**
- * Represents an axis-aligned bounding Rectangle for an element in a compose hierarchy, in the
- * coordinates of either the Root of the compose hierarchy, the Window, or the Screen.
- *
- * @see androidx.compose.ui.layout.onRectChanged
- */
-class RectInfo
-internal constructor(
-    private val topLeft: Long,
-    private val bottomRight: Long,
-    private val windowOffset: IntOffset,
-    private val screenOffset: IntOffset,
-    private val viewToWindowMatrix: Matrix?,
-    private val node: DelegatableNode,
-) {
-    /**
-     * The top left position of the Rect in the coordinates of the root node of the compose
-     * hierarchy.
-     */
-    val positionInRoot: IntOffset
-        get() = IntOffset(topLeft)
-
-    /** The top left position of the Rect in the coordinates of the Window it is contained in */
-    val positionInWindow: IntOffset
-        get() {
-            val x = screenOffset.x - windowOffset.x
-            val y = screenOffset.y - windowOffset.y
-            val l = unpackX(topLeft)
-            val t = unpackY(topLeft)
-            return IntOffset(l + x, t + y)
-        }
-
-    /** The top left position of the Rect in the coordinates of the Screen it is contained in. */
-    val positionInScreen: IntOffset
-        get() {
-            val x = screenOffset.x
-            val y = screenOffset.y
-            val l = unpackX(topLeft)
-            val t = unpackY(topLeft)
-            return IntOffset(l + x, t + y)
-        }
-
-    /** The width, in pixels, of the Rect */
-    val width: Int
-        get() {
-            val l = unpackX(topLeft)
-            val r = unpackX(bottomRight)
-            return r - l
-        }
-
-    /** The height, in pixels, of the Rect */
-    val height: Int
-        get() {
-            val t = unpackY(topLeft)
-            val b = unpackY(bottomRight)
-            return b - t
-        }
-
-    /**
-     * The positioned bounding Rect in the coordinates of the root node of the compose hierarchy.
-     */
-    val rootRect: IntRect
-        get() {
-            val l = unpackX(topLeft)
-            val t = unpackY(topLeft)
-            val r = unpackX(bottomRight)
-            val b = unpackY(bottomRight)
-            return IntRect(l, t, r, b)
-        }
-
-    /** The positioned bounding Rect in the coordinates of the Window which it is contained in. */
-    val windowRect: IntRect
-        get() {
-            val l = unpackX(topLeft)
-            val t = unpackY(topLeft)
-            val r = unpackX(bottomRight)
-            val b = unpackY(bottomRight)
-            if (viewToWindowMatrix != null) {
-                // TODO: we could implement a `Matrix.map(l, t, r, b): IntRect` that was only a
-                //  single allocation if we wanted to. this would avoid the two Rect(FFFF)
-                //  allocations that we have here.
-                return viewToWindowMatrix
-                    .map(Rect(l.toFloat(), t.toFloat(), r.toFloat(), b.toFloat()))
-                    .roundToIntRect()
-            }
-            val x = screenOffset.x - windowOffset.x
-            val y = screenOffset.y - windowOffset.y
-            return IntRect(l + x, t + y, r + x, b + y)
-        }
-
-    /** The positioned bounding Rect in the coordinates of the Screen which it is contained in. */
-    val screenRect: IntRect
-        get() {
-            if (viewToWindowMatrix != null) {
-                val windowRect = windowRect
-                val offset = windowOffset
-                return IntRect(
-                    windowRect.left + offset.x,
-                    windowRect.top + offset.y,
-                    windowRect.right + offset.x,
-                    windowRect.bottom + offset.y,
-                )
-            }
-            val l = unpackX(topLeft)
-            val t = unpackY(topLeft)
-            val r = unpackX(bottomRight)
-            val b = unpackY(bottomRight)
-            val x = screenOffset.x
-            val y = screenOffset.y
-            return IntRect(l + x, t + y, r + x, b + y)
-        }
-
-    /**
-     * At the current state of the layout, calculates which other Composable Layouts are occluding
-     * the Composable associated with this [RectInfo]. **Note**: Calling this method during measure
-     * or layout may result on calculations with stale (or partially stale) layout information.
-     *
-     * An occlusion is defined by an intersecting Composable that may draw on top of the target
-     * Composable.
-     *
-     * There's no guarantee that something was actually drawn to occlude, so a transparent
-     * Composable that could otherwise draw on top of the target is considered to be occluding.
-     *
-     * There's no differentiation between partial and complete occlusions, they are all included as
-     * part of this calculation.
-     *
-     * Ancestors, child and grandchild Layouts are never considered to be occluding.
-     *
-     * @return A [List] of the rectangles that occlude the associated Composable Layout.
-     */
-    fun calculateOcclusions(): List<IntRect> {
-        val rectManager = node.requireOwner().rectManager
-        val id = node.requireLayoutNode().semanticsId
-        val rectList = rectManager.rects
-        val idIndex = rectList.indexOf(id)
-        if (idIndex < 0) {
-            return emptyList()
-        }
-        // For the given `id`, finds intersections and determines occlusions by the result of
-        // the 'RectManager.isTargetDrawnFirst', if the node for 'id' is drawn first, then it's
-        // being occluded and the intersecting rect is added to the list result.
-        return buildList {
-            rectList.forEachIntersectingRectWithValueAt(idIndex) { l, t, r, b, intersectingId ->
-                if (rectManager.isTargetDrawnFirst(id, intersectingId)) {
-                    [email protected](IntRect(l, t, r, b))
-                }
-            }
-        }
-    }
-}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt
index 6e9cf7f..557e7ae 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RectManager.kt
@@ -138,8 +138,8 @@
         dispatchToken = postDelayed(delay, dispatchLambda)
     }
 
-    fun currentRectInfo(id: Int, node: DelegatableNode): RectInfo? {
-        var result: RectInfo? = null
+    fun currentRectInfo(id: Int, node: DelegatableNode): RelativeLayoutBounds? {
+        var result: RelativeLayoutBounds? = null
         rects.withRect(id) { l, t, r, b ->
             result =
                 rectInfoFor(
@@ -161,15 +161,15 @@
 
     fun registerOnRectChangedCallback(
         id: Int,
-        throttleMs: Int,
-        debounceMs: Int,
+        throttleMillis: Long,
+        debounceMillis: Long,
         node: DelegatableNode,
-        callback: (RectInfo) -> Unit
+        callback: (RelativeLayoutBounds) -> Unit
     ): DisposableHandle {
         return throttledCallbacks.registerOnRectChanged(
             id,
-            throttleMs.toLong(),
-            debounceMs.toLong(),
+            throttleMillis,
+            debounceMillis,
             node,
             callback,
         )
@@ -177,15 +177,15 @@
 
     fun registerOnGlobalLayoutCallback(
         id: Int,
-        throttleMs: Int,
-        debounceMs: Int,
+        throttleMillis: Long,
+        debounceMillis: Long,
         node: DelegatableNode,
-        callback: (RectInfo) -> Unit
+        callback: (RelativeLayoutBounds) -> Unit
     ): DisposableHandle {
         return throttledCallbacks.registerOnGlobalChange(
             id = id,
-            throttleMs = throttleMs.toLong(),
-            debounceMs = debounceMs.toLong(),
+            throttleMillis = throttleMillis,
+            debounceMillis = debounceMillis,
             node = node,
             callback = callback,
         )
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RelativeLayoutBounds.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RelativeLayoutBounds.kt
new file mode 100644
index 0000000..27dcde1
--- /dev/null
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/RelativeLayoutBounds.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2024 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 androidx.compose.ui.spatial
+
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.requireLayoutNode
+import androidx.compose.ui.node.requireOwner
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.roundToIntRect
+
+/**
+ * Represents an axis-aligned bounding Rectangle for an element in a compose hierarchy, in the
+ * coordinates of either the Root of the compose hierarchy, the Window, or the Screen.
+ *
+ * @see androidx.compose.ui.layout.onLayoutRectChanged
+ */
+class RelativeLayoutBounds
+internal constructor(
+    private val topLeft: Long,
+    private val bottomRight: Long,
+    private val windowOffset: IntOffset,
+    private val screenOffset: IntOffset,
+    private val viewToWindowMatrix: Matrix?,
+    private val node: DelegatableNode,
+) {
+    /**
+     * The top left position of the Rect in the coordinates of the root node of the compose
+     * hierarchy.
+     */
+    val positionInRoot: IntOffset
+        get() = IntOffset(topLeft)
+
+    /** The top left position of the Rect in the coordinates of the Window it is contained in */
+    val positionInWindow: IntOffset
+        get() {
+            val x = screenOffset.x - windowOffset.x
+            val y = screenOffset.y - windowOffset.y
+            val l = unpackX(topLeft)
+            val t = unpackY(topLeft)
+            return IntOffset(l + x, t + y)
+        }
+
+    /** The top left position of the Rect in the coordinates of the Screen it is contained in. */
+    val positionInScreen: IntOffset
+        get() {
+            val x = screenOffset.x
+            val y = screenOffset.y
+            val l = unpackX(topLeft)
+            val t = unpackY(topLeft)
+            return IntOffset(l + x, t + y)
+        }
+
+    /** The width, in pixels, of the Rect */
+    val width: Int
+        get() {
+            val l = unpackX(topLeft)
+            val r = unpackX(bottomRight)
+            return r - l
+        }
+
+    /** The height, in pixels, of the Rect */
+    val height: Int
+        get() {
+            val t = unpackY(topLeft)
+            val b = unpackY(bottomRight)
+            return b - t
+        }
+
+    /**
+     * The positioned bounding Rect in the coordinates of the root node of the compose hierarchy.
+     */
+    val boundsInRoot: IntRect
+        get() {
+            val l = unpackX(topLeft)
+            val t = unpackY(topLeft)
+            val r = unpackX(bottomRight)
+            val b = unpackY(bottomRight)
+            return IntRect(l, t, r, b)
+        }
+
+    /** The positioned bounding Rect in the coordinates of the Window which it is contained in. */
+    val boundsInWindow: IntRect
+        get() {
+            val l = unpackX(topLeft)
+            val t = unpackY(topLeft)
+            val r = unpackX(bottomRight)
+            val b = unpackY(bottomRight)
+            if (viewToWindowMatrix != null) {
+                // TODO: we could implement a `Matrix.map(l, t, r, b): IntRect` that was only a
+                //  single allocation if we wanted to. this would avoid the two Rect(FFFF)
+                //  allocations that we have here.
+                return viewToWindowMatrix
+                    .map(Rect(l.toFloat(), t.toFloat(), r.toFloat(), b.toFloat()))
+                    .roundToIntRect()
+            }
+            val x = screenOffset.x - windowOffset.x
+            val y = screenOffset.y - windowOffset.y
+            return IntRect(l + x, t + y, r + x, b + y)
+        }
+
+    /** The positioned bounding Rect in the coordinates of the Screen which it is contained in. */
+    val boundsInScreen: IntRect
+        get() {
+            if (viewToWindowMatrix != null) {
+                val windowRect = boundsInWindow
+                val offset = windowOffset
+                return IntRect(
+                    windowRect.left + offset.x,
+                    windowRect.top + offset.y,
+                    windowRect.right + offset.x,
+                    windowRect.bottom + offset.y,
+                )
+            }
+            val l = unpackX(topLeft)
+            val t = unpackY(topLeft)
+            val r = unpackX(bottomRight)
+            val b = unpackY(bottomRight)
+            val x = screenOffset.x
+            val y = screenOffset.y
+            return IntRect(l + x, t + y, r + x, b + y)
+        }
+
+    /**
+     * At the current state of the layout, calculates which other Composable Layouts are occluding
+     * the Composable associated with this [RelativeLayoutBounds]. **Note**: Calling this method
+     * during measure or layout may result on calculations with stale (or partially stale) layout
+     * information.
+     *
+     * An occlusion is defined by an intersecting Composable that may draw on top of the target
+     * Composable.
+     *
+     * There's no guarantee that something was actually drawn to occlude, so a transparent
+     * Composable that could otherwise draw on top of the target is considered to be occluding.
+     *
+     * There's no differentiation between partial and complete occlusions, they are all included as
+     * part of this calculation.
+     *
+     * Ancestors, child and grandchild Layouts are never considered to be occluding.
+     *
+     * @return A [List] of the rectangles that occlude the associated Composable Layout.
+     */
+    fun calculateOcclusions(): List<IntRect> {
+        val rectManager = node.requireOwner().rectManager
+        val id = node.requireLayoutNode().semanticsId
+        val rectList = rectManager.rects
+        val idIndex = rectList.indexOf(id)
+        if (idIndex < 0) {
+            return emptyList()
+        }
+        // For the given `id`, finds intersections and determines occlusions by the result of
+        // the 'RectManager.isTargetDrawnFirst', if the node for 'id' is drawn first, then it's
+        // being occluded and the intersecting rect is added to the list result.
+        return buildList {
+            rectList.forEachIntersectingRectWithValueAt(idIndex) { l, t, r, b, intersectingId ->
+                if (rectManager.isTargetDrawnFirst(id, intersectingId)) {
+                    [email protected](IntRect(l, t, r, b))
+                }
+            }
+        }
+    }
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt
index b0f577b..54e02c3 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/spatial/ThrottledCallbacks.kt
@@ -31,7 +31,7 @@
 internal class ThrottledCallbacks {
 
     /**
-     * Entry for a throttled callback for [RectInfo] associated to the given [node].
+     * Entry for a throttled callback for [RelativeLayoutBounds] associated to the given [node].
      *
      * Supports a linked-list structure for multiple callbacks on the same [node] through [next].
      */
@@ -40,7 +40,7 @@
         val throttleMillis: Long,
         val debounceMillis: Long,
         val node: DelegatableNode,
-        val callback: (RectInfo) -> Unit,
+        val callback: (RelativeLayoutBounds) -> Unit,
     ) : DisposableHandle {
 
         var next: Entry? = null
@@ -119,21 +119,21 @@
 
     fun registerOnRectChanged(
         id: Int,
-        throttleMs: Long,
-        debounceMs: Long,
+        throttleMillis: Long,
+        debounceMillis: Long,
         node: DelegatableNode,
-        callback: (RectInfo) -> Unit,
+        callback: (RelativeLayoutBounds) -> Unit,
     ): DisposableHandle {
         // If zero is set for debounce, we use throttle in its place. This guarantees that
         // consumers will get the value where the node "settled".
-        val debounceToUse = if (debounceMs == 0L) throttleMs else debounceMs
+        val debounceToUse = if (debounceMillis == 0L) throttleMillis else debounceMillis
 
         return rectChangedMap.multiPut(
             key = id,
             value =
                 Entry(
                     id = id,
-                    throttleMillis = throttleMs,
+                    throttleMillis = throttleMillis,
                     debounceMillis = debounceToUse,
                     node = node,
                     callback = callback,
@@ -143,19 +143,19 @@
 
     fun registerOnGlobalChange(
         id: Int,
-        throttleMs: Long,
-        debounceMs: Long,
+        throttleMillis: Long,
+        debounceMillis: Long,
         node: DelegatableNode,
-        callback: (RectInfo) -> Unit,
+        callback: (RelativeLayoutBounds) -> Unit,
     ): DisposableHandle {
         // If zero is set for debounce, we use throttle in its place. This guarantees that
         // consumers will get the value where the node "settled".
-        val debounceToUse = if (debounceMs == 0L) throttleMs else debounceMs
+        val debounceToUse = if (debounceMillis == 0L) throttleMillis else debounceMillis
 
         val entry =
             Entry(
                 id = id,
-                throttleMillis = throttleMs,
+                throttleMillis = throttleMillis,
                 debounceMillis = debounceToUse,
                 node = node,
                 callback = callback,
@@ -455,7 +455,7 @@
     windowOffset: IntOffset,
     screenOffset: IntOffset,
     viewToWindowMatrix: Matrix?,
-): RectInfo? {
+): RelativeLayoutBounds? {
     val coordinator = node.requireCoordinator(Nodes.Layout)
     val layoutNode = node.requireLayoutNode()
     if (!layoutNode.isPlaced) return null
@@ -467,7 +467,7 @@
     val needsTransform = layoutNode.outerCoordinator !== coordinator
     return if (needsTransform) {
         val transformed = layoutNode.outerCoordinator.coordinates.localBoundingBoxOf(coordinator)
-        RectInfo(
+        RelativeLayoutBounds(
             transformed.topLeft.round().packedValue,
             transformed.bottomRight.round().packedValue,
             windowOffset,
@@ -476,7 +476,7 @@
             node,
         )
     } else
-        RectInfo(
+        RelativeLayoutBounds(
             topLeft,
             bottomRight,
             windowOffset,
diff --git a/constraintlayout/constraintlayout-compose/integration-tests/compose-benchmark/build.gradle b/constraintlayout/constraintlayout-compose/integration-tests/compose-benchmark/build.gradle
index d821fc0..9e1b39c 100644
--- a/constraintlayout/constraintlayout-compose/integration-tests/compose-benchmark/build.gradle
+++ b/constraintlayout/constraintlayout-compose/integration-tests/compose-benchmark/build.gradle
@@ -23,11 +23,11 @@
 }
 
 dependencies {
-    androidTestImplementation project(":constraintlayout:constraintlayout-compose")
-    androidTestImplementation project(":constraintlayout:constraintlayout-core")
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
+    androidTestImplementation(project(":constraintlayout:constraintlayout-compose"))
+    androidTestImplementation(project(":constraintlayout:constraintlayout-core"))
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintLayout.java b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintLayout.java
index 75e2599..94ccc7b 100644
--- a/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintLayout.java
+++ b/constraintlayout/constraintlayout/src/main/java/androidx/constraintlayout/widget/ConstraintLayout.java
@@ -2775,7 +2775,6 @@
          *
          * @param params the Layout Params to be copied
          */
-        @SuppressLint("ClassVerificationFailure")
         public LayoutParams(ViewGroup.LayoutParams params) {
             super(params);
 
diff --git a/coordinatorlayout/coordinatorlayout/build.gradle b/coordinatorlayout/coordinatorlayout/build.gradle
index d5677b9..352cb89 100644
--- a/coordinatorlayout/coordinatorlayout/build.gradle
+++ b/coordinatorlayout/coordinatorlayout/build.gradle
@@ -36,12 +36,6 @@
 }
 
 android {
-    sourceSets {
-        main.res.srcDirs = [
-                "src/main/res",
-                "src/main/res-public"
-        ]
-    }
     buildTypes.configureEach {
         consumerProguardFiles "proguard-rules.pro"
     }
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
index 0dedce5..967e398 100644
--- a/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
@@ -29,6 +29,12 @@
             android:name="androidx.coordinatorlayout.widget.CoordinatorWithNestedScrollViewsActivity"
             />
 
+        <activity
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat.Light"
+            android:name="androidx.coordinatorlayout.widget.CoordinatorWithRecyclerViewActivity"
+            />
+
         <activity android:name="androidx.coordinatorlayout.widget.DynamicCoordinatorLayoutActivity"/>
 
     </application>
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java
new file mode 100644
index 0000000..eed663e
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2024 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 androidx.coordinatorlayout.widget;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.pressKey;
+import static androidx.test.espresso.action.ViewActions.swipeUp;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+
+import static org.junit.Assert.assertEquals;
+
+import android.graphics.Rect;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.coordinatorlayout.test.R;
+import androidx.coordinatorlayout.testutils.AppBarStateChangedListener;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.testutils.PollingCheck;
+
+import com.google.android.material.appbar.AppBarLayout;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+@SuppressWarnings({"unchecked", "rawtypes"})
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class CoordinatorLayoutWithRecyclerViewKeyEventTest {
+
+    @Rule
+    public ActivityScenarioRule<CoordinatorWithRecyclerViewActivity> mActivityScenarioRule =
+            new ActivityScenarioRule(CoordinatorWithRecyclerViewActivity.class);
+
+    private AppBarLayout mAppBarLayout;
+    private RecyclerView mRecyclerView;
+    // Used to verify that the RecyclerView's item location is zero when using the UP Key.
+    private LinearLayoutManager mLinearLayoutManager;
+
+    private AppBarStateChangedListener.State mAppBarState =
+            AppBarStateChangedListener.State.UNKNOWN;
+
+    public static Matcher<View> isAtLeastHalfVisible() {
+        return new TypeSafeMatcher<View>() {
+            @Override
+            protected boolean matchesSafely(View view) {
+                Rect rect = new Rect();
+                return view.getGlobalVisibleRect(rect)
+                        && rect.width() * rect.height() >= (view.getWidth() * view.getHeight()) / 2;
+            }
+            @Override
+            public void describeTo(Description description) {
+                description.appendText("is at least half visible on screen");
+            }
+        };
+    }
+
+    @Before
+    public void setup() {
+        mActivityScenarioRule.getScenario().onActivity(activity -> {
+            mAppBarLayout = activity.mAppBarLayout;
+            mRecyclerView = activity.mRecyclerView;
+            mLinearLayoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
+
+            mAppBarLayout.addOnOffsetChangedListener(new AppBarStateChangedListener() {
+                @Override
+                public void onStateChanged(AppBarLayout appBarLayout, State state) {
+                    mAppBarState = state;
+                }
+            });
+        });
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+    }
+
+    /*** Tests ***/
+    @Test
+    @LargeTest
+    public void isCollapsingToolbarExpanded_swipeDownMultipleKeysUp_isExpanded() {
+        onView(withId(R.id.recycler_view)).check(matches(isAtLeastHalfVisible()));
+
+        // Scrolls down content and collapses the CollapsingToolbarLayout in the AppBarLayout.
+        onView(withId(R.id.coordinator)).perform(swipeUp());
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        // Espresso doesn't properly support swipeUp() with a CoordinatorLayout,
+        // AppBarLayout/CollapsingToolbarLayout, and RecyclerView. From testing, it only
+        // handles waiting until the AppBarLayout/CollapsingToolbarLayout is finished with its
+        // transition, NOT waiting until the RecyclerView is finished with its scrolling.
+        // This PollingCheck waits until the scroll is finished in the RecyclerView.
+        AtomicInteger previousScroll = new AtomicInteger();
+        PollingCheck.waitFor(() -> {
+            AtomicInteger currentScroll = new AtomicInteger();
+
+            mActivityScenarioRule.getScenario().onActivity(activity -> {
+                currentScroll.set(activity.mRecyclerView.getScrollY());
+            });
+
+            boolean isDone = currentScroll.get() == previousScroll.get();
+            previousScroll.set(currentScroll.get());
+
+            return isDone;
+        });
+
+        // Verifies the CollapsingToolbarLayout in the AppBarLayout is collapsed.
+        assertEquals(AppBarStateChangedListener.State.COLLAPSED, mAppBarState);
+        onView(withId(R.id.recycler_view)).check(matches(isCompletelyDisplayed()));
+
+        // First up keystroke gains focus (doesn't move any content).
+        onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        // Retrieve top visible item in the RecyclerView.
+        int currentTopVisibleItem = mLinearLayoutManager.findFirstCompletelyVisibleItemPosition();
+
+        // Scroll up to the 0 position in the RecyclerView via UP Keystroke.
+        while (currentTopVisibleItem > 0) {
+            onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+            InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+            currentTopVisibleItem = mLinearLayoutManager.findFirstCompletelyVisibleItemPosition();
+        }
+
+        // This is a fail-safe in case the DPAD UP isn't making any changes, we break out of the
+        // loop.
+        float previousAppBarLayoutY = 0.0f;
+
+        // Performs a key press until the app bar is either expanded completely or no changes are
+        // made in the app bar between the previous call and the current call (failure case).
+        while (mAppBarState != AppBarStateChangedListener.State.EXPANDED
+                && (mAppBarLayout.getY() != previousAppBarLayoutY)
+        ) {
+            previousAppBarLayoutY = mAppBarLayout.getY();
+
+            // Partially expands the CollapsingToolbarLayout.
+            onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+            InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+        }
+
+        // Checks CollapsingToolbarLayout (in the AppBarLayout) is fully expanded.
+        assertEquals(AppBarStateChangedListener.State.EXPANDED, mAppBarState);
+    }
+
+    @Test
+    @LargeTest
+    public void doesAppBarCollapse_pressKeyboardDownMultipleTimes() {
+        onView(withId(R.id.recycler_view)).check(matches(isAtLeastHalfVisible()));
+
+        // Scrolls down content (key) and collapses the CollapsingToolbarLayout in the AppBarLayout.
+        // Gains focus
+        onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_DOWN));
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        // This is a fail-safe in case the DPAD UP isn't making any changes, we break out of the
+        // loop.
+        float previousAppBarLayoutY = 0.0f;
+
+        // Performs a key press until the app bar is either completely collapsed or no changes are
+        // made in the app bar between the previous call and the current call (failure case).
+        while (mAppBarState != AppBarStateChangedListener.State.COLLAPSED
+                && (mAppBarLayout.getY() != previousAppBarLayoutY)
+        ) {
+            previousAppBarLayoutY = mAppBarLayout.getY();
+
+            // Partial collapse of the CollapsingToolbarLayout.
+            onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_DOWN));
+            InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+        }
+
+        // Espresso doesn't properly support down with a CoordinatorLayout,
+        // AppBarLayout/CollapsingToolbarLayout, and RecyclerView. From testing, it only
+        // handles waiting until the AppBarLayout/CollapsingToolbarLayout is finished with its
+        // transition, NOT waiting until the RecyclerView is finished with its scrolling.
+        // This PollingCheck waits until the scroll is finished in the RecyclerView.
+        AtomicInteger previousScroll = new AtomicInteger();
+        PollingCheck.waitFor(() -> {
+            AtomicInteger currentScroll = new AtomicInteger();
+
+            mActivityScenarioRule.getScenario().onActivity(activity -> {
+                currentScroll.set(activity.mRecyclerView.getScrollY());
+            });
+
+            boolean isDone = currentScroll.get() == previousScroll.get();
+            previousScroll.set(currentScroll.get());
+
+            return isDone;
+        });
+
+        // Verifies the CollapsingToolbarLayout in the AppBarLayout is collapsed.
+        assertEquals(AppBarStateChangedListener.State.COLLAPSED, mAppBarState);
+        onView(withId(R.id.recycler_view)).check(matches(isCompletelyDisplayed()));
+    }
+}
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java
new file mode 100644
index 0000000..52c3a7a
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 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 androidx.coordinatorlayout.widget;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.coordinatorlayout.BaseTestActivity;
+import androidx.coordinatorlayout.test.R;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.AppBarLayout;
+import com.google.android.material.appbar.CollapsingToolbarLayout;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CoordinatorWithRecyclerViewActivity extends BaseTestActivity {
+    AppBarLayout mAppBarLayout;
+    RecyclerView mRecyclerView;
+
+    @Override
+    protected int getContentViewLayoutResId() {
+        return R.layout.activity_coordinator_with_recycler_view;
+    }
+
+    @Override
+    protected void onContentViewSet() {
+        mAppBarLayout = findViewById(R.id.app_bar_layout);
+        mRecyclerView = findViewById(R.id.recycler_view);
+
+        CollapsingToolbarLayout collapsingToolbarLayout =
+                findViewById(R.id.collapsing_toolbar_layout);
+
+        collapsingToolbarLayout.setTitle("Collapsing Bar Test");
+
+        mRecyclerView = findViewById(R.id.recycler_view);
+        mRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+
+        List<String> data = new ArrayList<String>();
+        for (int index = 0; index < 14; index++) {
+            data.add(String.valueOf(index));
+        }
+
+        MyAdapter adapter = new MyAdapter(data);
+        mRecyclerView.setAdapter(adapter);
+    }
+
+    public static class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder> {
+        private final List<String> mDataForItems;
+
+        public MyAdapter(@NonNull List<String> items) {
+            this.mDataForItems = items;
+        }
+
+        public static class ViewHolder extends RecyclerView.ViewHolder {
+            @NonNull public TextView textViewHeader;
+            @NonNull public TextView textViewSubHeader;
+
+            public ViewHolder(@NonNull View itemView) {
+                super(itemView);
+                textViewHeader = itemView.findViewById(R.id.textViewHeader);
+                textViewSubHeader = itemView.findViewById(R.id.textViewSubHeader);
+            }
+        }
+
+        @NonNull
+        @Override
+        public MyAdapter.ViewHolder onCreateViewHolder(
+                @NonNull ViewGroup parent,
+                int viewType
+        ) {
+            View itemView = LayoutInflater.from(parent.getContext()).inflate(
+                    R.layout.recycler_view_with_collapsing_toolbar_list_item,
+                    parent,
+                    false
+            );
+            return new ViewHolder(itemView);
+        }
+
+        @Override
+        public void onBindViewHolder(
+                MyAdapter.ViewHolder holder,
+                int position
+        ) {
+            String number = mDataForItems.get(position);
+
+            holder.textViewHeader.setText(number);
+            holder.textViewSubHeader.setText("Sub Header for " + number);
+        }
+
+        @Override
+        public int getItemCount() {
+            return mDataForItems.size();
+        }
+    }
+}
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml
new file mode 100644
index 0000000..91bd00f
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 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.
+-->
+<!-- Uses a recyclerview with collapsing app bar AND sets scroll flag to include snap. -->
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/coordinator"
+    android:fitsSystemWindows="true"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <!-- App Bar -->
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/app_bar_layout"
+        android:layout_width="match_parent"
+        android:layout_height="200dp">
+
+        <com.google.android.material.appbar.CollapsingToolbarLayout
+            android:id="@+id/collapsing_toolbar_layout"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            app:expandedTitleMarginStart="48dp"
+            app:expandedTitleMarginEnd="64dp"
+            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
+
+            <View android:layout_width="match_parent"
+                android:layout_height="200dp"
+                android:background="#FF0000"/>
+
+            <androidx.appcompat.widget.Toolbar
+                android:id="@+id/toolbar"
+                android:layout_width="match_parent"
+                android:layout_height="?attr/actionBarSize"
+                app:layout_collapseMode="pin" />
+        </com.google.android.material.appbar.CollapsingToolbarLayout>
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <!-- Content -->
+    <FrameLayout
+        android:layout_height="match_parent"
+        android:layout_width="match_parent"
+        android:orientation="vertical"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/recycler_view"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+        </androidx.recyclerview.widget.RecyclerView>
+    </FrameLayout>
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml
new file mode 100644
index 0000000..d44cdf1
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright (C) 2024 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.
+  -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="16dp"
+    android:focusable="true">
+    <TextView
+        android:id="@+id/textViewHeader"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="24sp" />
+    <TextView
+        android:id="@+id/textViewSubHeader"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14sp" />
+</LinearLayout>
diff --git a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
index 1e76df6..0b20ce4 100644
--- a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
+++ b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
@@ -116,8 +116,8 @@
         NestedScrollingParent3 {
     static final String TAG = "CoordinatorLayout";
     static final String WIDGET_PACKAGE_NAME;
-    // For the UP/DOWN keys, we scroll 1/10th of the screen.
-    private static final float KEY_SCROLL_FRACTION_AMOUNT = 0.1f;
+    // For the UP/DOWN keys, we scroll 20% of the screen.
+    private static final float KEY_SCROLL_FRACTION_AMOUNT = 0.2f;
 
     static {
         final Package pkg = CoordinatorLayout.class.getPackage();
@@ -2083,6 +2083,16 @@
                 ViewCompat.TYPE_NON_TOUCH
         );
 
+        onNestedPreScroll(
+                focusedView,
+                0,
+                yScrollDelta,
+                mKeyTriggeredScrollConsumed,
+                ViewCompat.TYPE_NON_TOUCH
+        );
+
+        int yScrollDeltaConsumed = mKeyTriggeredScrollConsumed[1];
+
         // Reset consumed values to zero.
         mKeyTriggeredScrollConsumed[0] = 0;
         mKeyTriggeredScrollConsumed[1] = 0;
@@ -2090,7 +2100,7 @@
         onNestedScroll(
                 focusedView,
                 0,
-                0,
+                yScrollDeltaConsumed,
                 0,
                 yScrollDelta,
                 ViewCompat.TYPE_NON_TOUCH,
diff --git a/coordinatorlayout/coordinatorlayout/src/main/res-public/values/public_attrs.xml b/coordinatorlayout/coordinatorlayout/src/main/res/values/public_attrs.xml
similarity index 100%
rename from coordinatorlayout/coordinatorlayout/src/main/res-public/values/public_attrs.xml
rename to coordinatorlayout/coordinatorlayout/src/main/res/values/public_attrs.xml
diff --git a/coordinatorlayout/coordinatorlayout/src/main/res-public/values/public_styles.xml b/coordinatorlayout/coordinatorlayout/src/main/res/values/public_styles.xml
similarity index 100%
rename from coordinatorlayout/coordinatorlayout/src/main/res-public/values/public_styles.xml
rename to coordinatorlayout/coordinatorlayout/src/main/res/values/public_styles.xml
diff --git a/core/core-animation-integration-tests/testapp/build.gradle b/core/core-animation-integration-tests/testapp/build.gradle
index 389b42d..d4bb678 100644
--- a/core/core-animation-integration-tests/testapp/build.gradle
+++ b/core/core-animation-integration-tests/testapp/build.gradle
@@ -20,6 +20,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     implementation("androidx.core:core:1.1.0")
     implementation(project(":core:core-animation"))
diff --git a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorSetTest.java b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorSetTest.java
index c15630f..6e60761f 100644
--- a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorSetTest.java
+++ b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorSetTest.java
@@ -26,11 +26,11 @@
 
 import android.util.Property;
 
-import androidx.annotation.NonNull;
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.ClassRule;
diff --git a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorTestRuleIsolationTest.java b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorTestRuleIsolationTest.java
index ecd2390..d999723 100644
--- a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorTestRuleIsolationTest.java
+++ b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/AnimatorTestRuleIsolationTest.java
@@ -20,11 +20,11 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import androidx.annotation.NonNull;
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/ObjectAnimatorTest.java b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/ObjectAnimatorTest.java
index aed16bd..8217931 100644
--- a/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/ObjectAnimatorTest.java
+++ b/core/core-animation-integration-tests/testapp/src/androidTest/java/androidx/core/animation/ObjectAnimatorTest.java
@@ -25,11 +25,11 @@
 import android.graphics.PointF;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.ClassRule;
@@ -693,9 +693,8 @@
         };
         TypeConverter<PointF, Float> converter = new TypeConverter<PointF, Float>(
                 PointF.class, Float.class) {
-            @NonNull
             @Override
-            public Float convert(@NonNull PointF value) {
+            public @NonNull Float convert(@NonNull PointF value) {
                 return (float) Math.sqrt(value.x * value.x + value.y * value.y);
             }
         };
diff --git a/core/core-animation-testing/build.gradle b/core/core-animation-testing/build.gradle
index 890dcf3..85f0418 100644
--- a/core/core-animation-testing/build.gradle
+++ b/core/core-animation-testing/build.gradle
@@ -29,6 +29,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     implementation("androidx.core:core:1.3.1")
     implementation(project(":core:core-animation"))
@@ -41,8 +42,6 @@
     mavenVersion = LibraryVersions.CORE_ANIMATION_TESTING
     inceptionYear = "2018"
     description = "This library provides functionalities for testing animations for API 14 and above."
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/core/core-animation-testing/src/main/java/androidx/core/animation/AnimatorTestRule.java b/core/core-animation-testing/src/main/java/androidx/core/animation/AnimatorTestRule.java
index d25ce57..44098f2 100644
--- a/core/core-animation-testing/src/main/java/androidx/core/animation/AnimatorTestRule.java
+++ b/core/core-animation-testing/src/main/java/androidx/core/animation/AnimatorTestRule.java
@@ -20,8 +20,7 @@
 import android.os.SystemClock;
 import android.util.AndroidRuntimeException;
 
-import androidx.annotation.NonNull;
-
+import org.jspecify.annotations.NonNull;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
 import org.junit.runners.model.Statement;
@@ -68,9 +67,9 @@
         mTestHandler = new AnimationHandler(new TestProvider());
     }
 
-    @NonNull
     @Override
-    public Statement apply(@NonNull final Statement base, @NonNull Description description) {
+    public @NonNull Statement apply(final @NonNull Statement base,
+            @NonNull Description description) {
         return new Statement() {
             @Override
             public void evaluate() throws Throwable {
diff --git a/core/core-animation/build.gradle b/core/core-animation/build.gradle
index d7d4494..39f5909 100644
--- a/core/core-animation/build.gradle
+++ b/core/core-animation/build.gradle
@@ -29,6 +29,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     implementation("androidx.core:core:1.13.0")
     implementation("androidx.collection:collection:1.4.2")
@@ -44,8 +45,6 @@
     mavenVersion = LibraryVersions.CORE_ANIMATION
     inceptionYear = "2018"
     description = "This library provides functionalities for creating and manipulating animations for API 14 and above."
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AccelerateInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/AccelerateInterpolator.java
index de01686..9db911a 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AccelerateInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AccelerateInterpolator.java
@@ -23,7 +23,8 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
+
+import org.jspecify.annotations.NonNull;
 
 /**
  * An interpolator where the rate of change starts out slowly and
diff --git a/core/core-animation/src/main/java/androidx/core/animation/Animator.java b/core/core-animation/src/main/java/androidx/core/animation/Animator.java
index b4c7171..a66821d 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/Animator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/Animator.java
@@ -19,8 +19,9 @@
 import android.annotation.SuppressLint;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 
@@ -183,8 +184,7 @@
      *
      * @param duration The length of the animation, in milliseconds.
      */
-    @NonNull
-    public abstract Animator setDuration(@IntRange(from = 0) long duration);
+    public abstract @NonNull Animator setDuration(@IntRange(from = 0) long duration);
 
     /**
      * Gets the duration of the animation.
@@ -232,8 +232,7 @@
      *
      * @return The timing interpolator for this animation.
      */
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         return null;
     }
 
@@ -299,8 +298,7 @@
      *
      * @return ArrayList<AnimatorListener> The set of listeners.
      */
-    @Nullable
-    ArrayList<AnimatorListener> getListeners() {
+    @Nullable ArrayList<AnimatorListener> getListeners() {
         return mListeners;
     }
 
@@ -391,9 +389,8 @@
     }
 
     @SuppressLint("NoClone") /* Platform API */
-    @NonNull
     @Override
-    public Animator clone() {
+    public @NonNull Animator clone() {
         try {
             final Animator anim = (Animator) super.clone();
             if (mListeners != null) {
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AnimatorInflater.java b/core/core-animation/src/main/java/androidx/core/animation/AnimatorInflater.java
index fa5e929..f2e805d 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AnimatorInflater.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AnimatorInflater.java
@@ -29,10 +29,10 @@
 
 import androidx.annotation.AnimatorRes;
 import androidx.annotation.InterpolatorRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.graphics.PathParser;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -76,8 +76,7 @@
      * @return The animator object reference by the specified id
      * @throws NotFoundException when the animation cannot be loaded
      */
-    @NonNull
-    public static Animator loadAnimator(@NonNull Context context, @AnimatorRes int id)
+    public static @NonNull Animator loadAnimator(@NonNull Context context, @AnimatorRes int id)
             throws NotFoundException {
         return loadAnimator(context.getResources(), context.getTheme(), id);
     }
@@ -91,9 +90,8 @@
      * @return The animator object reference by the specified id
      * @throws NotFoundException when the animation cannot be loaded
      */
-    @NonNull
-    public static Animator loadAnimator(@NonNull Resources resources, @Nullable Theme theme,
-            @AnimatorRes int id) throws NotFoundException {
+    public static @NonNull Animator loadAnimator(@NonNull Resources resources,
+            @Nullable Theme theme, @AnimatorRes int id) throws NotFoundException {
         return loadAnimator(resources, theme, id, 1);
     }
 
@@ -134,11 +132,10 @@
     static class PathDataEvaluator implements TypeEvaluator<PathParser.PathDataNode[]> {
         private PathParser.PathDataNode[] mPathData;
 
-        @NonNull
         @Override
-        public PathParser.PathDataNode[] evaluate(
-                float fraction, @NonNull PathParser.PathDataNode[] startPathData,
-                @NonNull PathParser.PathDataNode[] endPathData) {
+        public PathParser.PathDataNode @NonNull [] evaluate(
+                float fraction, PathParser.PathDataNode @NonNull [] startPathData,
+                PathParser.PathDataNode @NonNull [] endPathData) {
             if (mPathData == null) {
                 // This path buffer has to have the same size and structure as the morphing path.
                 mPathData = PathParser.deepCopyNodes(endPathData);
@@ -845,8 +842,7 @@
      * @return The animation object reference by the specified id
      * @throws NotFoundException when interpolator resources cannot be loaded
      */
-    @NonNull
-    public static Interpolator loadInterpolator(@NonNull Context context,
+    public static @NonNull Interpolator loadInterpolator(@NonNull Context context,
             @AnimatorRes @InterpolatorRes int id) throws NotFoundException {
         XmlResourceParser parser = null;
         try {
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AnimatorListenerAdapter.java b/core/core-animation/src/main/java/androidx/core/animation/AnimatorListenerAdapter.java
index 1d660d7..4c1d5e4 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AnimatorListenerAdapter.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AnimatorListenerAdapter.java
@@ -16,7 +16,7 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This adapter class provides empty implementations of the methods from
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AnimatorSet.java b/core/core-animation/src/main/java/androidx/core/animation/AnimatorSet.java
index fc71818..bb837e5 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AnimatorSet.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AnimatorSet.java
@@ -22,11 +22,12 @@
 import android.util.Log;
 import android.view.animation.Animation;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.collection.SimpleArrayMap;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -194,7 +195,7 @@
      *
      * @param items The animations that will be started simultaneously.
      */
-    public void playTogether(@NonNull Animator... items) {
+    public void playTogether(Animator @NonNull ... items) {
         if (items != null) {
             Builder builder = play(items[0]);
             for (int i = 1; i < items.length; ++i) {
@@ -227,7 +228,7 @@
      *
      * @param items The animations that will be started one after another.
      */
-    public void playSequentially(@NonNull Animator... items) {
+    public void playSequentially(Animator @NonNull ... items) {
         if (items != null) {
             if (items.length == 1) {
                 play(items[0]);
@@ -266,8 +267,7 @@
      * @return ArrayList<Animator> The list of child animations of this AnimatorSet.
      */
     @SuppressLint("ConcreteCollection") /* Platform API */
-    @NonNull
-    public ArrayList<Animator> getChildAnimations() {
+    public @NonNull ArrayList<Animator> getChildAnimations() {
         ArrayList<Animator> childList = new ArrayList<Animator>();
 
         int size = mNodes.size();
@@ -316,8 +316,7 @@
     }
 
     @Override
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         return mInterpolator;
     }
 
@@ -349,8 +348,7 @@
      * outlined in the calls to <code>play</code> and the other methods in the
      * <code>Builder</code object.
      */
-    @NonNull
-    public Builder play(@NonNull Animator anim) {
+    public @NonNull Builder play(@NonNull Animator anim) {
         return new Builder(anim);
     }
 
@@ -538,8 +536,7 @@
      * animations of this AnimatorSet.
      */
     @Override
-    @NonNull
-    public AnimatorSet setDuration(long duration) {
+    public @NonNull AnimatorSet setDuration(long duration) {
         if (duration < 0) {
             throw new IllegalArgumentException("duration must be a value of zero or greater");
         }
@@ -1257,9 +1254,8 @@
     }
 
     @SuppressLint("NoClone") /* Platform API */
-    @NonNull
     @Override
-    public AnimatorSet clone() {
+    public @NonNull AnimatorSet clone() {
         final AnimatorSet anim = (AnimatorSet) super.clone();
         /*
          * The basic clone() operation copies all items. This doesn't work very well for
@@ -1716,9 +1712,8 @@
             this.mAnimation = animation;
         }
 
-        @NonNull
         @Override
-        public Node clone() {
+        public @NonNull Node clone() {
             try {
                 Node node = (Node) super.clone();
                 node.mAnimation = mAnimation.clone();
@@ -1811,9 +1806,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             String eventStr = mEvent == ANIMATION_START ? "start" : (
                     mEvent == ANIMATION_DELAY_ENDED ? "delay ended" : "end");
             return eventStr + " " + mNode.mAnimation.toString();
@@ -1951,8 +1945,7 @@
          * @param anim The animation that will play when the animation supplied to the
          * {@link AnimatorSet#play(Animator)} method starts.
          */
-        @NonNull
-        public Builder with(@NonNull Animator anim) {
+        public @NonNull Builder with(@NonNull Animator anim) {
             Node node = getNodeForAnimation(anim);
             mCurrentNode.addSibling(node);
             return this;
@@ -1966,8 +1959,7 @@
          * @param anim The animation that will play when the animation supplied to the
          * {@link AnimatorSet#play(Animator)} method ends.
          */
-        @NonNull
-        public Builder before(@NonNull Animator anim) {
+        public @NonNull Builder before(@NonNull Animator anim) {
             Node node = getNodeForAnimation(anim);
             mCurrentNode.addChild(node);
             return this;
@@ -1981,8 +1973,7 @@
          * @param anim The animation whose end will cause the animation supplied to the
          * {@link AnimatorSet#play(Animator)} method to play.
          */
-        @NonNull
-        public Builder after(@NonNull Animator anim) {
+        public @NonNull Builder after(@NonNull Animator anim) {
             Node node = getNodeForAnimation(anim);
             mCurrentNode.addParent(node);
             return this;
@@ -1996,8 +1987,7 @@
          * @param delay The number of milliseconds that should elapse before the
          * animation starts.
          */
-        @NonNull
-        public Builder after(long delay) {
+        public @NonNull Builder after(long delay) {
             // setup no-op ValueAnimator just to run the clock
             ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
             anim.setDuration(delay);
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AnticipateInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/AnticipateInterpolator.java
index 21e29d6..388c089 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AnticipateInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AnticipateInterpolator.java
@@ -23,8 +23,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An interpolator where the change starts backward then flings forward.
diff --git a/core/core-animation/src/main/java/androidx/core/animation/AnticipateOvershootInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/AnticipateOvershootInterpolator.java
index ba8a687..e3081ab 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/AnticipateOvershootInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/AnticipateOvershootInterpolator.java
@@ -23,8 +23,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An interpolator where the change starts backward then flings forward and overshoots
diff --git a/core/core-animation/src/main/java/androidx/core/animation/ArgbEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/ArgbEvaluator.java
index 0d87507..ce3c37a 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/ArgbEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/ArgbEvaluator.java
@@ -18,7 +18,7 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This evaluator can be used to perform type interpolation between integer
@@ -33,8 +33,7 @@
      * be used in multiple <code>Animator</code>s because it holds no state.
      * @return An instance of <code>ArgbEvaluator</code>.
      */
-    @NonNull
-    public static ArgbEvaluator getInstance() {
+    public static @NonNull ArgbEvaluator getInstance() {
         return sInstance;
     }
 
@@ -59,8 +58,7 @@
      */
     @SuppressLint("AutoBoxing") /* Generics */
     @Override
-    @NonNull
-    public Integer evaluate(
+    public @NonNull Integer evaluate(
             float fraction,
             @SuppressLint("AutoBoxing") @NonNull Integer startValue,
             @SuppressLint("AutoBoxing") @NonNull Integer endValue
diff --git a/core/core-animation/src/main/java/androidx/core/animation/BidirectionalTypeConverter.java b/core/core-animation/src/main/java/androidx/core/animation/BidirectionalTypeConverter.java
index c8f7a73..8ce5a26 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/BidirectionalTypeConverter.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/BidirectionalTypeConverter.java
@@ -15,7 +15,7 @@
  */
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Abstract base class used convert type T to another type V and back again. This
@@ -48,8 +48,7 @@
      * @param value The Object to convert.
      * @return A value of type T, converted from <code>value</code>.
      */
-    @NonNull
-    public abstract T convertBack(@NonNull V value);
+    public abstract @NonNull T convertBack(@NonNull V value);
 
     /**
      * Returns the inverse of this converter, where the from and to classes are reversed.
@@ -58,8 +57,7 @@
      * {@link #convertBack(Object)} calls.
      * @return The inverse of this converter, where the from and to classes are reversed.
      */
-    @NonNull
-    public BidirectionalTypeConverter<V, T> invert() {
+    public @NonNull BidirectionalTypeConverter<V, T> invert() {
         if (mInvertedConverter == null) {
             mInvertedConverter = new InvertedConverter<>(this);
         }
@@ -74,15 +72,13 @@
             mConverter = converter;
         }
 
-        @NonNull
         @Override
-        public From convertBack(@NonNull To value) {
+        public @NonNull From convertBack(@NonNull To value) {
             return mConverter.convert(value);
         }
 
-        @NonNull
         @Override
-        public To convert(@NonNull From value) {
+        public @NonNull To convert(@NonNull From value) {
             return mConverter.convertBack(value);
         }
     }
diff --git a/core/core-animation/src/main/java/androidx/core/animation/CycleInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/CycleInterpolator.java
index 437b465..2ccc9c0 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/CycleInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/CycleInterpolator.java
@@ -23,8 +23,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Repeats the animation for a specified number of cycles. The
diff --git a/core/core-animation/src/main/java/androidx/core/animation/DecelerateInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/DecelerateInterpolator.java
index 3483c0b..958b33e 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/DecelerateInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/DecelerateInterpolator.java
@@ -23,8 +23,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An interpolator where the rate of change starts out quickly and
diff --git a/core/core-animation/src/main/java/androidx/core/animation/FloatArrayEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/FloatArrayEvaluator.java
index b89e5d0..bb4769f 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/FloatArrayEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/FloatArrayEvaluator.java
@@ -17,8 +17,8 @@
 package androidx.core.animation;
 
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>float[]</code> values.
@@ -48,7 +48,7 @@
      *
      * @param reuseArray The array to modify and return from <code>evaluate</code>.
      */
-    public FloatArrayEvaluator(@Nullable float[] reuseArray) {
+    public FloatArrayEvaluator(float @Nullable [] reuseArray) {
         mArray = reuseArray;
     }
 
@@ -65,9 +65,8 @@
      *         the same index in startValue and endValue.
      */
     @Override
-    @NonNull
-    public float[] evaluate(float fraction, @NonNull float[] startValue,
-            @NonNull float[] endValue) {
+    public float @NonNull [] evaluate(float fraction, float @NonNull [] startValue,
+            float @NonNull [] endValue) {
         float[] array = mArray;
         if (array == null) {
             array = new float[startValue.length];
diff --git a/core/core-animation/src/main/java/androidx/core/animation/FloatEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/FloatEvaluator.java
index 3797d5c..816ea0b 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/FloatEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/FloatEvaluator.java
@@ -18,7 +18,7 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>float</code> values.
@@ -33,8 +33,7 @@
      * be used in multiple <code>Animator</code>s because it holds no state.
      * @return An instance of <code>FloatEvaluator</code>.
      */
-    @NonNull
-    public static FloatEvaluator getInstance() {
+    public static @NonNull FloatEvaluator getInstance() {
         return sInstance;
     }
 
@@ -57,8 +56,7 @@
      */
     @SuppressLint("AutoBoxing") /* Generics */
     @Override
-    @NonNull
-    public Float evaluate(
+    public @NonNull Float evaluate(
             float fraction,
             @SuppressLint("AutoBoxing") @NonNull Float startValue,
             @SuppressLint("AutoBoxing") @NonNull Float endValue
diff --git a/core/core-animation/src/main/java/androidx/core/animation/FloatKeyframeSet.java b/core/core-animation/src/main/java/androidx/core/animation/FloatKeyframeSet.java
index 3eeb3b6..45af7aa 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/FloatKeyframeSet.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/FloatKeyframeSet.java
@@ -16,9 +16,10 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
 import androidx.core.animation.Keyframe.FloatKeyframe;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 
 /**
@@ -41,9 +42,8 @@
         return getFloatValue(fraction);
     }
 
-    @NonNull
     @Override
-    public FloatKeyframeSet clone() {
+    public @NonNull FloatKeyframeSet clone() {
         final List<Keyframe<Float>> keyframes = mKeyframes;
         final int numKeyframes = mKeyframes.size();
         FloatKeyframe[] newKeyframes = new FloatKeyframe[numKeyframes];
diff --git a/core/core-animation/src/main/java/androidx/core/animation/FloatProperty.java b/core/core-animation/src/main/java/androidx/core/animation/FloatProperty.java
index d967a5e3..13ccacdf 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/FloatProperty.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/FloatProperty.java
@@ -18,7 +18,7 @@
 import android.annotation.SuppressLint;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * An implementation of {@link android.util.Property} to be used specifically with fields of type
diff --git a/core/core-animation/src/main/java/androidx/core/animation/IntArrayEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/IntArrayEvaluator.java
index d3d4edb..55969e4 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/IntArrayEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/IntArrayEvaluator.java
@@ -16,8 +16,8 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>int[]</code> values.
@@ -47,7 +47,7 @@
      *
      * @param reuseArray The array to modify and return from <code>evaluate</code>.
      */
-    public IntArrayEvaluator(@Nullable int[] reuseArray) {
+    public IntArrayEvaluator(int @Nullable [] reuseArray) {
         mArray = reuseArray;
     }
 
@@ -63,8 +63,8 @@
      *         the same index in startValue and endValue.
      */
     @Override
-    @NonNull
-    public int[] evaluate(float fraction, @NonNull int[] startValue, @NonNull int[] endValue) {
+    public int @NonNull [] evaluate(float fraction, int @NonNull [] startValue,
+            int @NonNull [] endValue) {
         int[] array = mArray;
         if (array == null) {
             array = new int[startValue.length];
diff --git a/core/core-animation/src/main/java/androidx/core/animation/IntEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/IntEvaluator.java
index f09fa14..fb2919c 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/IntEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/IntEvaluator.java
@@ -18,7 +18,7 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>int</code> values.
@@ -33,8 +33,7 @@
      * be used in multiple <code>Animator</code>s because it holds no state.
      * @return An instance of <code>IntEvaluator</code>.
      */
-    @NonNull
-    public static IntEvaluator getInstance() {
+    public static @NonNull IntEvaluator getInstance() {
         return sInstance;
     }
 
@@ -57,8 +56,7 @@
      */
     @SuppressLint("AutoBoxing") /* Generics */
     @Override
-    @NonNull
-    public Integer evaluate(
+    public @NonNull Integer evaluate(
             float fraction,
             @SuppressLint("AutoBoxing") @NonNull Integer startValue,
             @SuppressLint("AutoBoxing") @NonNull Integer endValue
diff --git a/core/core-animation/src/main/java/androidx/core/animation/IntKeyframeSet.java b/core/core-animation/src/main/java/androidx/core/animation/IntKeyframeSet.java
index a43a19b7..71ff949 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/IntKeyframeSet.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/IntKeyframeSet.java
@@ -16,9 +16,10 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
 import androidx.core.animation.Keyframe.IntKeyframe;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 
 /**
@@ -41,9 +42,8 @@
         return getIntValue(fraction);
     }
 
-    @NonNull
     @Override
-    public IntKeyframeSet clone() {
+    public @NonNull IntKeyframeSet clone() {
         List<Keyframe<Integer>> keyframes = mKeyframes;
         int numKeyframes = mKeyframes.size();
         IntKeyframe[] newKeyframes = new IntKeyframe[numKeyframes];
diff --git a/core/core-animation/src/main/java/androidx/core/animation/IntProperty.java b/core/core-animation/src/main/java/androidx/core/animation/IntProperty.java
index 78c0d3a..02bf783 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/IntProperty.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/IntProperty.java
@@ -18,7 +18,7 @@
 import android.annotation.SuppressLint;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * An implementation of {@link android.util.Property} to be used specifically with fields of type
diff --git a/core/core-animation/src/main/java/androidx/core/animation/Keyframe.java b/core/core-animation/src/main/java/androidx/core/animation/Keyframe.java
index ca2a121..a283634c 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/Keyframe.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/Keyframe.java
@@ -19,8 +19,9 @@
 import android.annotation.SuppressLint;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This class holds a time/value pair for an animation. The Keyframe class is used
@@ -85,8 +86,8 @@
      * the time in this keyframe, and the the value animated from as the time passes the time in
      * this keyframe.
      */
-    @NonNull
-    public static Keyframe<Integer> ofInt(@FloatRange(from = 0, to = 1) float fraction, int value) {
+    public static @NonNull Keyframe<Integer> ofInt(@FloatRange(from = 0, to = 1) float fraction,
+            int value) {
         return new IntKeyframe(fraction, value);
     }
 
@@ -102,8 +103,7 @@
      * @param fraction The time, expressed as a value between 0 and 1, representing the fraction
      * of time elapsed of the overall animation duration.
      */
-    @NonNull
-    public static Keyframe<Integer> ofInt(@FloatRange(from = 0, to = 1) float fraction) {
+    public static @NonNull Keyframe<Integer> ofInt(@FloatRange(from = 0, to = 1) float fraction) {
         return new IntKeyframe(fraction);
     }
 
@@ -119,8 +119,7 @@
      * the time in this keyframe, and the the value animated from as the time passes the time in
      * this keyframe.
      */
-    @NonNull
-    public static Keyframe<Float> ofFloat(@FloatRange(from = 0, to = 1) float fraction,
+    public static @NonNull Keyframe<Float> ofFloat(@FloatRange(from = 0, to = 1) float fraction,
             float value) {
         return new FloatKeyframe(fraction, value);
     }
@@ -137,8 +136,7 @@
      * @param fraction The time, expressed as a value between 0 and 1, representing the fraction
      * of time elapsed of the overall animation duration.
      */
-    @NonNull
-    public static Keyframe<Float> ofFloat(@FloatRange(from = 0, to = 1) float fraction) {
+    public static @NonNull Keyframe<Float> ofFloat(@FloatRange(from = 0, to = 1) float fraction) {
         return new FloatKeyframe(fraction);
     }
 
@@ -154,8 +152,7 @@
      * the time in this keyframe, and the the value animated from as the time passes the time in
      * this keyframe.
      */
-    @NonNull
-    public static <T> Keyframe<T> ofObject(@FloatRange(from = 0, to = 1) float fraction,
+    public static <T> @NonNull Keyframe<T> ofObject(@FloatRange(from = 0, to = 1) float fraction,
                 @Nullable T value) {
         return new ObjectKeyframe<T>(fraction, value);
     }
@@ -172,8 +169,7 @@
      * @param fraction The time, expressed as a value between 0 and 1, representing the fraction
      * of time elapsed of the overall animation duration.
      */
-    @NonNull
-    public static <T> Keyframe<T> ofObject(@FloatRange(from = 0, to = 1) float fraction) {
+    public static <T> @NonNull Keyframe<T> ofObject(@FloatRange(from = 0, to = 1) float fraction) {
         return new ObjectKeyframe<>(fraction, null);
     }
 
@@ -210,8 +206,7 @@
     //TODO: Consider removing hasValue() and making keyframe always contain nonNull value, and
     // using a different signal for when the animation should read the property value as its start
     // value.
-    @Nullable
-    public abstract T getValue();
+    public abstract @Nullable T getValue();
 
     /**
      * Sets the value for this Keyframe.
@@ -246,8 +241,7 @@
      *
      * @return The optional interpolator for this Keyframe.
      */
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         return mInterpolator;
     }
 
@@ -266,15 +260,13 @@
      *
      * @return The type of the value stored in the Keyframe.
      */
-    @NonNull
-    public Class<?> getType() {
+    public @NonNull Class<?> getType() {
         return mValueType;
     }
 
     @SuppressLint("NoClone") /* Platform API */
-    @NonNull
     @Override
-    public abstract Keyframe<T> clone();
+    public abstract @NonNull Keyframe<T> clone();
 
     /**
      * This internal subclass is used for all types which are not int or float.
@@ -304,9 +296,8 @@
             mHasValue = (value != null);
         }
 
-        @NonNull
         @Override
-        public ObjectKeyframe<T> clone() {
+        public @NonNull ObjectKeyframe<T> clone() {
             ObjectKeyframe<T> kfClone = new ObjectKeyframe<>(getFraction(),
                     hasValue() ? mValue : null);
             kfClone.mValueWasSetOnStart = mValueWasSetOnStart;
@@ -354,9 +345,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public IntKeyframe clone() {
+        public @NonNull IntKeyframe clone() {
             IntKeyframe kfClone = mHasValue ? new IntKeyframe(getFraction(), mValue) :
                     new IntKeyframe(getFraction());
             kfClone.setInterpolator(getInterpolator());
@@ -403,9 +393,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public FloatKeyframe clone() {
+        public @NonNull FloatKeyframe clone() {
             FloatKeyframe kfClone = mHasValue ? new FloatKeyframe(getFraction(), mValue) :
                     new FloatKeyframe(getFraction());
             kfClone.setInterpolator(getInterpolator());
diff --git a/core/core-animation/src/main/java/androidx/core/animation/KeyframeSet.java b/core/core-animation/src/main/java/androidx/core/animation/KeyframeSet.java
index 518c889..640b55a 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/KeyframeSet.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/KeyframeSet.java
@@ -19,10 +19,11 @@
 import android.graphics.Path;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.core.animation.Keyframe.FloatKeyframe;
 import androidx.core.animation.Keyframe.IntKeyframe;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -184,9 +185,8 @@
         return mFirstKeyframe.getType();
     }
 
-    @NonNull
     @Override
-    public KeyframeSet<T> clone() {
+    public @NonNull KeyframeSet<T> clone() {
         List<Keyframe<T>> keyframes = mKeyframes;
         int numKeyframes = mKeyframes.size();
         final ArrayList<Keyframe<T>> newKeyframes = new ArrayList<>(numKeyframes);
@@ -265,9 +265,8 @@
         return mLastKeyframe.getValue();
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         String returnVal = " ";
         for (int i = 0; i < mNumKeyframes; ++i) {
             returnVal += mKeyframes.get(i).getValue() + "  ";
diff --git a/core/core-animation/src/main/java/androidx/core/animation/Keyframes.java b/core/core-animation/src/main/java/androidx/core/animation/Keyframes.java
index a552de5..a680f3f 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/Keyframes.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/Keyframes.java
@@ -15,7 +15,7 @@
  */
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.List;
 
@@ -60,8 +60,7 @@
      */
     List<Keyframe<T>> getKeyframes();
 
-    @NonNull
-    Keyframes clone();
+    @NonNull Keyframes clone();
 
     /**
      * A specialization of Keyframes that has integer primitive value calculation.
diff --git a/core/core-animation/src/main/java/androidx/core/animation/LinearInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/LinearInterpolator.java
index 8ae8373..da20421 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/LinearInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/LinearInterpolator.java
@@ -20,8 +20,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An interpolator where the rate of change is constant
diff --git a/core/core-animation/src/main/java/androidx/core/animation/ObjectAnimator.java b/core/core-animation/src/main/java/androidx/core/animation/ObjectAnimator.java
index e6b9d8c..c0ed8b9 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/ObjectAnimator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/ObjectAnimator.java
@@ -23,8 +23,9 @@
 import android.util.Property;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.lang.ref.WeakReference;
 
@@ -160,8 +161,7 @@
      * object (if there was just one) or a comma-separated list of all of the
      * names (if there are more than one).</p>
      */
-    @NonNull
-    public String getPropertyName() {
+    public @NonNull String getPropertyName() {
         String propertyName = null;
         if (mPropertyName != null) {
             propertyName = mPropertyName;
@@ -181,8 +181,7 @@
     }
 
     @Override
-    @NonNull
-    public String getNameForTrace() {
+    public @NonNull String getNameForTrace() {
         return mAnimTraceName == null ? "animator:" + getPropertyName() : mAnimTraceName;
     }
 
@@ -234,9 +233,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofInt(@NonNull Object target, @NonNull String propertyName,
-            @NonNull int... values) {
+    public static @NonNull ObjectAnimator ofInt(@NonNull Object target,
+            @NonNull String propertyName, int @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, propertyName);
         anim.setIntValues(values);
         return anim;
@@ -259,9 +257,8 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static ObjectAnimator ofInt(@NonNull Object target, @NonNull String xPropertyName,
-            @NonNull String yPropertyName, @NonNull Path path) {
+    public static @NonNull ObjectAnimator ofInt(@NonNull Object target,
+            @NonNull String xPropertyName, @NonNull String yPropertyName, @NonNull Path path) {
         PathKeyframes keyframes = KeyframeSet.ofPath(path);
         PropertyValuesHolder x = PropertyValuesHolder.ofKeyframes(xPropertyName,
                 keyframes.createXIntKeyframes());
@@ -283,9 +280,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static <T> ObjectAnimator ofInt(@NonNull T target,
-            @NonNull Property<T, Integer> property, @NonNull int... values) {
+    public static <T> @NonNull ObjectAnimator ofInt(@NonNull T target,
+            @NonNull Property<T, Integer> property, int @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, property);
         anim.setIntValues(values);
         return anim;
@@ -304,8 +300,7 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static <T> ObjectAnimator ofInt(@NonNull T target,
+    public static <T> @NonNull ObjectAnimator ofInt(@NonNull T target,
             @Nullable Property<T, Integer> xProperty, @Nullable Property<T, Integer> yProperty,
             @NonNull Path path) {
         PathKeyframes keyframes = KeyframeSet.ofPath(path);
@@ -332,11 +327,10 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofMultiInt(
+    public static @NonNull ObjectAnimator ofMultiInt(
             @NonNull Object target,
             @NonNull String propertyName,
-            @SuppressLint("ArrayReturn") /* Platform API */ @NonNull int[][] values
+            @SuppressLint("ArrayReturn") /* Platform API */ int @NonNull [][] values
     ) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiInt(propertyName, values);
         return ofPropertyValuesHolder(target, pvh);
@@ -357,9 +351,8 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static ObjectAnimator ofMultiInt(@NonNull Object target, @NonNull String propertyName,
-            @NonNull Path path) {
+    public static @NonNull ObjectAnimator ofMultiInt(@NonNull Object target,
+            @NonNull String propertyName, @NonNull Path path) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiInt(propertyName, path);
         return ofPropertyValuesHolder(target, pvh);
     }
@@ -384,10 +377,9 @@
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
     @SafeVarargs
-    @NonNull
-    public static <T> ObjectAnimator ofMultiInt(@NonNull Object target,
+    public static <T> @NonNull ObjectAnimator ofMultiInt(@NonNull Object target,
             @NonNull String propertyName, @NonNull TypeConverter<T, int[]> converter,
-            @NonNull TypeEvaluator<T> evaluator, @NonNull T... values) {
+            @NonNull TypeEvaluator<T> evaluator, T @NonNull ... values) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiInt(propertyName, converter,
                 evaluator, values);
         return ObjectAnimator.ofPropertyValuesHolder(target, pvh);
@@ -408,9 +400,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofArgb(@NonNull Object target, @NonNull String propertyName,
-            @NonNull int... values) {
+    public static @NonNull ObjectAnimator ofArgb(@NonNull Object target,
+            @NonNull String propertyName, int @NonNull ... values) {
         ObjectAnimator animator = ofInt(target, propertyName, values);
         animator.setEvaluator(ArgbEvaluator.getInstance());
         return animator;
@@ -429,9 +420,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static <T> ObjectAnimator ofArgb(@NonNull T target,
-            @NonNull Property<T, Integer> property, @NonNull int... values) {
+    public static <T> @NonNull ObjectAnimator ofArgb(@NonNull T target,
+            @NonNull Property<T, Integer> property, int @NonNull ... values) {
         ObjectAnimator animator = ofInt(target, property, values);
         animator.setEvaluator(ArgbEvaluator.getInstance());
         return animator;
@@ -452,9 +442,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofFloat(@NonNull Object target, @NonNull String propertyName,
-            @NonNull float... values) {
+    public static @NonNull ObjectAnimator ofFloat(@NonNull Object target,
+            @NonNull String propertyName, float @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, propertyName);
         anim.setFloatValues(values);
         return anim;
@@ -477,9 +466,8 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static ObjectAnimator ofFloat(@NonNull Object target, @Nullable String xPropertyName,
-            @Nullable String yPropertyName, @NonNull Path path) {
+    public static @NonNull ObjectAnimator ofFloat(@NonNull Object target,
+            @Nullable String xPropertyName, @Nullable String yPropertyName, @NonNull Path path) {
         PathKeyframes keyframes = KeyframeSet.ofPath(path);
         PropertyValuesHolder x = PropertyValuesHolder.ofKeyframes(xPropertyName,
                 keyframes.createXFloatKeyframes());
@@ -501,9 +489,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static <T> ObjectAnimator ofFloat(@NonNull T target,
-            @NonNull Property<T, Float> property, @NonNull float... values) {
+    public static <T> @NonNull ObjectAnimator ofFloat(@NonNull T target,
+            @NonNull Property<T, Float> property, float @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, property);
         anim.setFloatValues(values);
         return anim;
@@ -522,8 +509,7 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static <T> ObjectAnimator ofFloat(@NonNull T target,
+    public static <T> @NonNull ObjectAnimator ofFloat(@NonNull T target,
             @Nullable Property<T, Float> xProperty, @Nullable Property<T, Float> yProperty,
             @NonNull Path path) {
         PathKeyframes keyframes = KeyframeSet.ofPath(path);
@@ -550,11 +536,10 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofMultiFloat(
+    public static @NonNull ObjectAnimator ofMultiFloat(
             @NonNull Object target,
             @NonNull String propertyName,
-            @SuppressLint("ArrayReturn") /* Platform API */ @NonNull float[][] values
+            @SuppressLint("ArrayReturn") /* Platform API */ float @NonNull [][] values
     ) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiFloat(propertyName, values);
         return ofPropertyValuesHolder(target, pvh);
@@ -575,9 +560,8 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static ObjectAnimator ofMultiFloat(@NonNull Object target, @NonNull String propertyName,
-            @NonNull Path path) {
+    public static @NonNull ObjectAnimator ofMultiFloat(@NonNull Object target,
+            @NonNull String propertyName, @NonNull Path path) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiFloat(propertyName, path);
         return ofPropertyValuesHolder(target, pvh);
     }
@@ -602,10 +586,9 @@
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
     @SafeVarargs
-    @NonNull
-    public static <T> ObjectAnimator ofMultiFloat(@NonNull Object target,
+    public static <T> @NonNull ObjectAnimator ofMultiFloat(@NonNull Object target,
             @NonNull String propertyName, @NonNull TypeConverter<T, float[]> converter,
-            @NonNull TypeEvaluator<T> evaluator, @NonNull T... values) {
+            @NonNull TypeEvaluator<T> evaluator, T @NonNull ... values) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofMultiFloat(propertyName, converter,
                 evaluator, values);
         return ObjectAnimator.ofPropertyValuesHolder(target, pvh);
@@ -634,9 +617,9 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofObject(@NonNull Object target, @NonNull String propertyName,
-            @NonNull TypeEvaluator evaluator, @NonNull Object... values) {
+    public static @NonNull ObjectAnimator ofObject(@NonNull Object target,
+            @NonNull String propertyName, @NonNull TypeEvaluator evaluator,
+            Object @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, propertyName);
         anim.setObjectValues(values);
         anim.setEvaluator(evaluator);
@@ -661,9 +644,9 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static ObjectAnimator ofObject(@NonNull Object target, @NonNull String propertyName,
-            @Nullable TypeConverter<PointF, ?> converter, @NonNull Path path) {
+    public static @NonNull ObjectAnimator ofObject(@NonNull Object target,
+            @NonNull String propertyName, @Nullable TypeConverter<PointF, ?> converter,
+            @NonNull Path path) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofObject(propertyName, converter, path);
         return ofPropertyValuesHolder(target, pvh);
     }
@@ -689,11 +672,10 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
     @SafeVarargs
-    public static <T, V> ObjectAnimator ofObject(@NonNull T target,
+    public static <T, V> @NonNull ObjectAnimator ofObject(@NonNull T target,
             @NonNull Property<T, V> property, @NonNull TypeEvaluator<V> evaluator,
-            @NonNull V... values) {
+            V @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator(target, property);
         anim.setObjectValues(values);
         anim.setEvaluator(evaluator);
@@ -725,11 +707,10 @@
      * @param values A set of values that the animation will animate between over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
     @SafeVarargs
-    public static <T, V, P> ObjectAnimator ofObject(@NonNull T target,
+    public static <T, V, P> @NonNull ObjectAnimator ofObject(@NonNull T target,
             @NonNull Property<T, P> property, @NonNull TypeConverter<V, P> converter,
-            @NonNull TypeEvaluator<V> evaluator, @NonNull V... values) {
+            @NonNull TypeEvaluator<V> evaluator, V @NonNull ... values) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofObject(property, converter, evaluator,
                 values);
         return ofPropertyValuesHolder(target, pvh);
@@ -754,8 +735,7 @@
      * @param path The <code>Path</code> to animate values along.
      * @return An ObjectAnimator object that is set up to animate along <code>path</code>.
      */
-    @NonNull
-    public static <T, V> ObjectAnimator ofObject(@NonNull T target,
+    public static <T, V> @NonNull ObjectAnimator ofObject(@NonNull T target,
             @NonNull Property<T, V> property, @Nullable TypeConverter<PointF, V> converter,
             @NonNull Path path) {
         PropertyValuesHolder pvh = PropertyValuesHolder.ofObject(property, converter, path);
@@ -779,9 +759,8 @@
      * over time.
      * @return An ObjectAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ObjectAnimator ofPropertyValuesHolder(@NonNull Object target,
-            @NonNull PropertyValuesHolder... values) {
+    public static @NonNull ObjectAnimator ofPropertyValuesHolder(@NonNull Object target,
+            PropertyValuesHolder @NonNull ... values) {
         ObjectAnimator anim = new ObjectAnimator();
         anim.setTarget(target);
         anim.setValues(values);
@@ -789,7 +768,7 @@
     }
 
     @Override
-    public void setIntValues(@NonNull int... values) {
+    public void setIntValues(int @NonNull ... values) {
         if (mValues == null || mValues.length == 0) {
             // No values yet - this animator is being constructed piecemeal. Init the values with
             // whatever the current propertyName is
@@ -804,7 +783,7 @@
     }
 
     @Override
-    public void setFloatValues(@NonNull float... values) {
+    public void setFloatValues(float @NonNull ... values) {
         if (mValues == null || mValues.length == 0) {
             // No values yet - this animator is being constructed piecemeal. Init the values with
             // whatever the current propertyName is
@@ -819,7 +798,7 @@
     }
 
     @Override
-    public void setObjectValues(@NonNull Object... values) {
+    public void setObjectValues(Object @NonNull ... values) {
         if (mValues == null || mValues.length == 0) {
             // No values yet - this animator is being constructed piecemeal. Init the values with
             // whatever the current propertyName is
@@ -936,8 +915,7 @@
      * <code>ObjectAnimator.ofInt(target, propertyName, 0, 10).setDuration(500).start()</code>.
      */
     @Override
-    @NonNull
-    public ObjectAnimator setDuration(long duration) {
+    public @NonNull ObjectAnimator setDuration(long duration) {
         super.setDuration(duration);
         return this;
     }
@@ -948,8 +926,7 @@
      *
      * @return The object being animated
      */
-    @Nullable
-    public Object getTarget() {
+    public @Nullable Object getTarget() {
         return mTarget == null ? null : mTarget.get();
     }
 
@@ -1028,16 +1005,14 @@
     }
 
     @SuppressLint("NoClone") /* Platform API */
-    @NonNull
     @Override
-    public ObjectAnimator clone() {
+    public @NonNull ObjectAnimator clone() {
         final ObjectAnimator anim = (ObjectAnimator) super.clone();
         return anim;
     }
 
     @Override
-    @NonNull
-    public String toString() {
+    public @NonNull String toString() {
         String returnVal = "ObjectAnimator@" + Integer.toHexString(hashCode()) + ", target "
                 + getTarget();
         if (mValues != null) {
diff --git a/core/core-animation/src/main/java/androidx/core/animation/OvershootInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/OvershootInterpolator.java
index 66c5fc8..bda7c92 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/OvershootInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/OvershootInterpolator.java
@@ -23,8 +23,9 @@
 import android.util.AttributeSet;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An interpolator where the change flings forward and overshoots the last value
diff --git a/core/core-animation/src/main/java/androidx/core/animation/PathInterpolator.java b/core/core-animation/src/main/java/androidx/core/animation/PathInterpolator.java
index b3f4195..b0bc350b 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/PathInterpolator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/PathInterpolator.java
@@ -24,14 +24,13 @@
 import android.view.InflateException;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.content.res.TypedArrayUtils;
 import androidx.core.graphics.PathParser;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 
-
 /**
  * An interpolator that can traverse a Path that extends from <code>Point</code>
  * <code>(0, 0)</code> to <code>(1, 1)</code>. The x coordinate along the <code>Path</code>
diff --git a/core/core-animation/src/main/java/androidx/core/animation/PathKeyframes.java b/core/core-animation/src/main/java/androidx/core/animation/PathKeyframes.java
index fe6cb0e..c6215d1 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/PathKeyframes.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/PathKeyframes.java
@@ -18,7 +18,7 @@
 import android.graphics.Path;
 import android.graphics.PointF;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -128,9 +128,8 @@
         return PointF.class;
     }
 
-    @NonNull
     @Override
-    public Keyframes clone() {
+    public @NonNull Keyframes clone() {
         Keyframes clone = null;
         try {
             clone = (Keyframes) super.clone();
@@ -218,10 +217,9 @@
             return mEmptyFrames;
         }
 
-        @NonNull
         @Override
         @SuppressWarnings({"unchecked", "CatchAndPrintStackTrace"})
-        public Keyframes<T> clone() {
+        public @NonNull Keyframes<T> clone() {
             Keyframes<T> clone = null;
             try {
                 clone = (Keyframes<T>) super.clone();
diff --git a/core/core-animation/src/main/java/androidx/core/animation/PointFEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/PointFEvaluator.java
index 201cde0..8f55951 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/PointFEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/PointFEvaluator.java
@@ -17,7 +17,7 @@
 
 import android.graphics.PointF;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>PointF</code> values.
@@ -71,8 +71,8 @@
      *         <code>fraction</code> parameter.
      */
     @Override
-    @NonNull
-    public PointF evaluate(float fraction, @NonNull PointF startValue, @NonNull PointF endValue) {
+    public @NonNull PointF evaluate(float fraction, @NonNull PointF startValue,
+            @NonNull PointF endValue) {
         float x = startValue.x + (fraction * (endValue.x - startValue.x));
         float y = startValue.y + (fraction * (endValue.y - startValue.y));
 
diff --git a/core/core-animation/src/main/java/androidx/core/animation/PropertyValuesHolder.java b/core/core-animation/src/main/java/androidx/core/animation/PropertyValuesHolder.java
index 27e973a..e88c06e 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/PropertyValuesHolder.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/PropertyValuesHolder.java
@@ -22,8 +22,8 @@
 import android.util.Log;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -147,8 +147,8 @@
      * @param values The values that the named property will animate between.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofInt(@NonNull String propertyName, @NonNull int... values) {
+    public static @NonNull PropertyValuesHolder ofInt(@NonNull String propertyName,
+            int @NonNull ... values) {
         return new IntPropertyValuesHolder(propertyName, values);
     }
 
@@ -159,9 +159,8 @@
      * @param values The values that the property will animate between.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofInt(@NonNull Property<?, Integer> property,
-            @NonNull int... values) {
+    public static @NonNull PropertyValuesHolder ofInt(@NonNull Property<?, Integer> property,
+            int @NonNull ... values) {
         return new IntPropertyValuesHolder(property, values);
     }
 
@@ -179,10 +178,9 @@
      * @see IntArrayEvaluator#IntArrayEvaluator(int[])
      * @see ObjectAnimator#ofMultiInt(Object, String, TypeConverter, TypeEvaluator, Object[])
      */
-    @NonNull
-    public static PropertyValuesHolder ofMultiInt(
+    public static @NonNull PropertyValuesHolder ofMultiInt(
             @NonNull String propertyName,
-            @SuppressLint("ArrayReturn") /* Platform API */ @NonNull int[][] values
+            @SuppressLint("ArrayReturn") /* Platform API */ int @NonNull [][] values
     ) {
         if (values.length < 2) {
             throw new IllegalArgumentException("At least 2 values must be supplied");
@@ -215,8 +213,7 @@
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      * @see ObjectAnimator#ofPropertyValuesHolder(Object, PropertyValuesHolder...)
      */
-    @NonNull
-    public static PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
+    public static @NonNull PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
             @NonNull Path path) {
         Keyframes keyframes = KeyframeSet.ofPath(path);
         PointFToIntArray converter = new PointFToIntArray();
@@ -240,10 +237,9 @@
      * @see ObjectAnimator#ofPropertyValuesHolder(Object, PropertyValuesHolder...)
      */
     @SafeVarargs
-    @NonNull
-    public static <V> PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
+    public static <V> @NonNull PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
             @NonNull TypeConverter<V, int[]> converter,
-            @NonNull TypeEvaluator<V> evaluator, @NonNull V... values) {
+            @NonNull TypeEvaluator<V> evaluator, V @NonNull ... values) {
         return new MultiIntValuesHolder(propertyName, converter, evaluator, values);
     }
 
@@ -265,10 +261,9 @@
      * @return A PropertyValuesHolder for a multi-int parameter setter.
      */
     @SafeVarargs
-    @NonNull
-    public static <T> PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
+    public static <T> @NonNull PropertyValuesHolder ofMultiInt(@NonNull String propertyName,
             @Nullable TypeConverter<T, int[]> converter, @NonNull TypeEvaluator<T> evaluator,
-            @NonNull Keyframe... values) {
+            Keyframe @NonNull ... values) {
         KeyframeSet keyframeSet = KeyframeSet.ofKeyframe(values);
         return new MultiIntValuesHolder(propertyName, converter, evaluator, keyframeSet);
     }
@@ -280,9 +275,8 @@
      * @param values The values that the named property will animate between.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofFloat(@NonNull String propertyName,
-            @NonNull float... values) {
+    public static @NonNull PropertyValuesHolder ofFloat(@NonNull String propertyName,
+            float @NonNull ... values) {
         return new FloatPropertyValuesHolder(propertyName, values);
     }
 
@@ -293,9 +287,8 @@
      * @param values The values that the property will animate between.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofFloat(@NonNull Property<?, Float> property,
-            @NonNull float... values) {
+    public static @NonNull PropertyValuesHolder ofFloat(@NonNull Property<?, Float> property,
+            float @NonNull ... values) {
         return new FloatPropertyValuesHolder(property, values);
     }
 
@@ -313,10 +306,9 @@
      * @see FloatArrayEvaluator#FloatArrayEvaluator(float[])
      * @see ObjectAnimator#ofMultiFloat(Object, String, TypeConverter, TypeEvaluator, Object[])
      */
-    @NonNull
-    public static PropertyValuesHolder ofMultiFloat(
+    public static @NonNull PropertyValuesHolder ofMultiFloat(
             @NonNull String propertyName,
-            @SuppressLint("ArrayReturn") /* Platform API */ @NonNull float[][] values
+            @SuppressLint("ArrayReturn") /* Platform API */ float @NonNull [][] values
     ) {
         if (values.length < 2) {
             throw new IllegalArgumentException("At least 2 values must be supplied");
@@ -349,8 +341,7 @@
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      * @see ObjectAnimator#ofPropertyValuesHolder(Object, PropertyValuesHolder...)
      */
-    @NonNull
-    public static PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
+    public static @NonNull PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
             @NonNull Path path) {
         Keyframes keyframes = KeyframeSet.ofPath(path);
         PointFToFloatArray converter = new PointFToFloatArray();
@@ -373,10 +364,9 @@
      * @see ObjectAnimator#ofMultiFloat(Object, String, TypeConverter, TypeEvaluator, Object[])
      */
     @SafeVarargs
-    @NonNull
-    public static <V> PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
+    public static <V> @NonNull PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
             @NonNull TypeConverter<V, float[]> converter,
-            @NonNull TypeEvaluator<V> evaluator, @NonNull V... values) {
+            @NonNull TypeEvaluator<V> evaluator, V @NonNull ... values) {
         return new MultiFloatValuesHolder(propertyName, converter, evaluator, values);
     }
 
@@ -398,11 +388,10 @@
      * @return A PropertyValuesHolder for a multi-float parameter setter.
      */
     @SafeVarargs
-    @NonNull
-    public static <T> PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
+    public static <T> @NonNull PropertyValuesHolder ofMultiFloat(@NonNull String propertyName,
             @Nullable TypeConverter<T, float[]> converter,
             @NonNull TypeEvaluator<T> evaluator,
-            @NonNull Keyframe... values) {
+            Keyframe @NonNull ... values) {
         KeyframeSet keyframeSet = KeyframeSet.ofKeyframe(values);
         return new MultiFloatValuesHolder(propertyName, converter, evaluator, keyframeSet);
     }
@@ -424,9 +413,8 @@
      * @param values The values that the named property will animate between.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofObject(@NonNull String propertyName,
-            @NonNull TypeEvaluator evaluator, @NonNull Object... values) {
+    public static @NonNull PropertyValuesHolder ofObject(@NonNull String propertyName,
+            @NonNull TypeEvaluator evaluator, Object @NonNull ... values) {
         PropertyValuesHolder pvh = new PropertyValuesHolder(propertyName);
         pvh.setObjectValues(values);
         pvh.setEvaluator(evaluator);
@@ -449,8 +437,7 @@
      * @param path The Path along which the values should be animated.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static PropertyValuesHolder ofObject(@NonNull String propertyName,
+    public static @NonNull PropertyValuesHolder ofObject(@NonNull String propertyName,
             @Nullable TypeConverter<PointF, ?> converter, @NonNull Path path) {
         PropertyValuesHolder pvh = new PropertyValuesHolder(propertyName);
         pvh.mKeyframes = KeyframeSet.ofPath(path);
@@ -477,9 +464,8 @@
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
     @SafeVarargs
-    @NonNull
-    public static <V> PropertyValuesHolder ofObject(@NonNull Property property,
-            @NonNull TypeEvaluator<V> evaluator, @NonNull V... values) {
+    public static <V> @NonNull PropertyValuesHolder ofObject(@NonNull Property property,
+            @NonNull TypeEvaluator<V> evaluator, V @NonNull ... values) {
         PropertyValuesHolder pvh = new PropertyValuesHolder(property);
         pvh.setObjectValues(values);
         pvh.setEvaluator(evaluator);
@@ -511,10 +497,9 @@
      * @see TypeConverter
      */
     @SafeVarargs
-    @NonNull
-    public static <T, V> PropertyValuesHolder ofObject(@NonNull Property<?, V> property,
+    public static <T, V> @NonNull PropertyValuesHolder ofObject(@NonNull Property<?, V> property,
             @NonNull TypeConverter<T, V> converter, @NonNull TypeEvaluator<T> evaluator,
-            @NonNull T... values) {
+            T @NonNull ... values) {
         PropertyValuesHolder pvh = new PropertyValuesHolder(property);
         pvh.setConverter(converter);
         pvh.setObjectValues(values);
@@ -538,8 +523,7 @@
      * @param path The Path along which the values should be animated.
      * @return PropertyValuesHolder The constructed PropertyValuesHolder object.
      */
-    @NonNull
-    public static <V> PropertyValuesHolder ofObject(@NonNull Property<?, V> property,
+    public static <V> @NonNull PropertyValuesHolder ofObject(@NonNull Property<?, V> property,
             @Nullable TypeConverter<PointF, V> converter, @NonNull Path path) {
         PropertyValuesHolder pvh = new PropertyValuesHolder(property);
         pvh.mKeyframes = KeyframeSet.ofPath(path);
@@ -567,9 +551,8 @@
      * @param values The set of values to animate between.
      */
     @SafeVarargs
-    @NonNull
-    public static PropertyValuesHolder ofKeyframe(@NonNull String propertyName,
-            @NonNull Keyframe... values) {
+    public static @NonNull PropertyValuesHolder ofKeyframe(@NonNull String propertyName,
+            Keyframe @NonNull ... values) {
         KeyframeSet keyframeSet = KeyframeSet.ofKeyframe(values);
         return ofKeyframes(propertyName, keyframeSet);
     }
@@ -591,9 +574,8 @@
      * @param values The set of values to animate between.
      */
     @SafeVarargs
-    @NonNull
-    public static PropertyValuesHolder ofKeyframe(@NonNull Property property,
-            @NonNull Keyframe... values) {
+    public static @NonNull PropertyValuesHolder ofKeyframe(@NonNull Property property,
+            Keyframe @NonNull ... values) {
         KeyframeSet keyframeSet = KeyframeSet.ofKeyframe(values);
         return ofKeyframes(property, keyframeSet);
     }
@@ -637,7 +619,7 @@
      *
      * @param values One or more values that the animation will animate between.
      */
-    public void setIntValues(@NonNull int... values) {
+    public void setIntValues(int @NonNull ... values) {
         mValueType = int.class;
         mKeyframes = KeyframeSet.ofInt(values);
     }
@@ -654,7 +636,7 @@
      *
      * @param values One or more values that the animation will animate between.
      */
-    public void setFloatValues(@NonNull float... values) {
+    public void setFloatValues(float @NonNull ... values) {
         mValueType = float.class;
         mKeyframes = KeyframeSet.ofFloat(values);
     }
@@ -664,7 +646,7 @@
      *
      * @param values One or more values that the animation will animate between.
      */
-    public void setKeyframes(@NonNull Keyframe... values) {
+    public void setKeyframes(Keyframe @NonNull ... values) {
         int numKeyframes = values.length;
         Keyframe[] keyframes = new Keyframe[Math.max(numKeyframes, 2)];
         mValueType = values[0].getType();
@@ -691,7 +673,7 @@
      *
      * @param values One or more values that the animation will animate between.
      */
-    public void setObjectValues(@NonNull Object... values) {
+    public void setObjectValues(Object @NonNull ... values) {
         mValueType = values[0].getClass();
         mKeyframes = KeyframeSet.ofObject(values);
         if (mEvaluator != null) {
@@ -979,9 +961,8 @@
     }
 
     @SuppressLint("NoClone") /* Platform API */
-    @NonNull
     @Override
-    public PropertyValuesHolder clone() {
+    public @NonNull PropertyValuesHolder clone() {
         try {
             PropertyValuesHolder newPVH = (PropertyValuesHolder) super.clone();
             newPVH.mPropertyName = mPropertyName;
@@ -1102,8 +1083,7 @@
      * <code>valueFrom</code> or <code>valueTo</code> is null, then a getter function will
      * also be derived and called.
      */
-    @NonNull
-    public String getPropertyName() {
+    public @NonNull String getPropertyName() {
         return mPropertyName;
     }
 
@@ -1120,9 +1100,8 @@
         return mValueType;
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return mPropertyName + ": " + mKeyframes.toString();
     }
 
@@ -1196,7 +1175,7 @@
         }
 
         @Override
-        public void setIntValues(@NonNull int... values) {
+        public void setIntValues(int @NonNull ... values) {
             super.setIntValues(values);
             mIntKeyframes = (Keyframes.IntKeyframes) mKeyframes;
         }
@@ -1211,9 +1190,8 @@
             return mIntAnimatedValue;
         }
 
-        @NonNull
         @Override
-        public IntPropertyValuesHolder clone() {
+        public @NonNull IntPropertyValuesHolder clone() {
             IntPropertyValuesHolder newPVH = (IntPropertyValuesHolder) super.clone();
             newPVH.mIntKeyframes = (Keyframes.IntKeyframes) newPVH.mKeyframes;
             return newPVH;
@@ -1295,7 +1273,7 @@
         }
 
         @Override
-        public void setFloatValues(@NonNull float... values) {
+        public void setFloatValues(float @NonNull ... values) {
             super.setFloatValues(values);
             mFloatKeyframes = (Keyframes.FloatKeyframes) mKeyframes;
         }
@@ -1310,9 +1288,8 @@
             return mFloatAnimatedValue;
         }
 
-        @NonNull
         @Override
-        public FloatPropertyValuesHolder clone() {
+        public @NonNull FloatPropertyValuesHolder clone() {
             FloatPropertyValuesHolder newPVH = (FloatPropertyValuesHolder) super.clone();
             newPVH.mFloatKeyframes = (Keyframes.FloatKeyframes) newPVH.mKeyframes;
             return newPVH;
@@ -1559,9 +1536,8 @@
             super(PointF.class, float[].class);
         }
 
-        @NonNull
         @Override
-        public float[] convert(@NonNull PointF value) {
+        public float @NonNull [] convert(@NonNull PointF value) {
             mCoordinates[0] = value.x;
             mCoordinates[1] = value.y;
             return mCoordinates;
@@ -1578,9 +1554,8 @@
             super(PointF.class, int[].class);
         }
 
-        @NonNull
         @Override
-        public int[] convert(@NonNull PointF value) {
+        public int @NonNull [] convert(@NonNull PointF value) {
             mCoordinates[0] = Math.round(value.x);
             mCoordinates[1] = Math.round(value.y);
             return mCoordinates;
diff --git a/core/core-animation/src/main/java/androidx/core/animation/RectEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/RectEvaluator.java
index 9f5e54f..8da9e3d 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/RectEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/RectEvaluator.java
@@ -17,7 +17,7 @@
 
 import android.graphics.Rect;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This evaluator can be used to perform type interpolation between <code>Rect</code> values.
@@ -71,8 +71,8 @@
      *         <code>fraction</code> parameter.
      */
     @Override
-    @NonNull
-    public Rect evaluate(float fraction, @NonNull Rect startValue, @NonNull Rect endValue) {
+    public @NonNull Rect evaluate(float fraction, @NonNull Rect startValue,
+            @NonNull Rect endValue) {
         int left = startValue.left + (int) ((endValue.left - startValue.left) * fraction);
         int top = startValue.top + (int) ((endValue.top - startValue.top) * fraction);
         int right = startValue.right + (int) ((endValue.right - startValue.right) * fraction);
diff --git a/core/core-animation/src/main/java/androidx/core/animation/TimeAnimator.java b/core/core-animation/src/main/java/androidx/core/animation/TimeAnimator.java
index f484716..a949c9c 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/TimeAnimator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/TimeAnimator.java
@@ -18,8 +18,8 @@
 
 import android.view.animation.AnimationUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This class provides a simple callback mechanism to listeners that is synchronized with all
diff --git a/core/core-animation/src/main/java/androidx/core/animation/TypeConverter.java b/core/core-animation/src/main/java/androidx/core/animation/TypeConverter.java
index 7cf4e3f..76dd7e6 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/TypeConverter.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/TypeConverter.java
@@ -16,7 +16,7 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Abstract base class used convert type T to another type V. This
diff --git a/core/core-animation/src/main/java/androidx/core/animation/TypeEvaluator.java b/core/core-animation/src/main/java/androidx/core/animation/TypeEvaluator.java
index 75b0c6e..9ae1737 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/TypeEvaluator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/TypeEvaluator.java
@@ -16,7 +16,7 @@
 
 package androidx.core.animation;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Interface for use with the {@link ValueAnimator#setEvaluator(TypeEvaluator)} function. Evaluators
@@ -42,7 +42,6 @@
      * @return A linear interpolation between the start and end values, given the
      *         <code>fraction</code> parameter.
      */
-    @NonNull
-    T evaluate(float fraction, @NonNull T startValue, @NonNull T endValue);
+    @NonNull T evaluate(float fraction, @NonNull T startValue, @NonNull T endValue);
 
 }
diff --git a/core/core-animation/src/main/java/androidx/core/animation/ValueAnimator.java b/core/core-animation/src/main/java/androidx/core/animation/ValueAnimator.java
index 20c2325..26f6eb6 100644
--- a/core/core-animation/src/main/java/androidx/core/animation/ValueAnimator.java
+++ b/core/core-animation/src/main/java/androidx/core/animation/ValueAnimator.java
@@ -24,10 +24,11 @@
 
 import androidx.annotation.CallSuper;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -298,8 +299,7 @@
      * @param values A set of values that the animation will animate between over time.
      * @return A ValueAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ValueAnimator ofInt(@NonNull int... values) {
+    public static @NonNull ValueAnimator ofInt(int @NonNull ... values) {
         ValueAnimator anim = new ValueAnimator();
         anim.setIntValues(values);
         return anim;
@@ -316,8 +316,7 @@
      * @param values A set of values that the animation will animate between over time.
      * @return A ValueAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ValueAnimator ofArgb(@NonNull int... values) {
+    public static @NonNull ValueAnimator ofArgb(int @NonNull ... values) {
         ValueAnimator anim = new ValueAnimator();
         anim.setIntValues(values);
         anim.setEvaluator(ArgbEvaluator.getInstance());
@@ -335,8 +334,7 @@
      * @param values A set of values that the animation will animate between over time.
      * @return A ValueAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ValueAnimator ofFloat(@NonNull float... values) {
+    public static @NonNull ValueAnimator ofFloat(float @NonNull ... values) {
         ValueAnimator anim = new ValueAnimator();
         anim.setFloatValues(values);
         return anim;
@@ -350,8 +348,8 @@
      * between over time.
      * @return A ValueAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ValueAnimator ofPropertyValuesHolder(@NonNull PropertyValuesHolder... values) {
+    public static @NonNull ValueAnimator ofPropertyValuesHolder(
+            PropertyValuesHolder @NonNull ... values) {
         ValueAnimator anim = new ValueAnimator();
         anim.setValues(values);
         return anim;
@@ -379,9 +377,8 @@
      * @param values A set of values that the animation will animate between over time.
      * @return A ValueAnimator object that is set up to animate between the given values.
      */
-    @NonNull
-    public static ValueAnimator ofObject(@NonNull TypeEvaluator evaluator,
-            @NonNull Object... values) {
+    public static @NonNull ValueAnimator ofObject(@NonNull TypeEvaluator evaluator,
+            Object @NonNull ... values) {
         ValueAnimator anim = new ValueAnimator();
         anim.setObjectValues(values);
         anim.setEvaluator(evaluator);
@@ -402,7 +399,7 @@
      *
      * @param values A set of values that the animation will animate between over time.
      */
-    public void setIntValues(@NonNull int... values) {
+    public void setIntValues(int @NonNull ... values) {
         if (values == null || values.length == 0) {
             return;
         }
@@ -430,7 +427,7 @@
      *
      * @param values A set of values that the animation will animate between over time.
      */
-    public void setFloatValues(@NonNull float... values) {
+    public void setFloatValues(float @NonNull ... values) {
         if (values == null || values.length == 0) {
             return;
         }
@@ -467,7 +464,7 @@
      *
      * @param values The set of values to animate between.
      */
-    public void setObjectValues(@NonNull Object... values) {
+    public void setObjectValues(Object @NonNull ... values) {
         if (values == null || values.length == 0) {
             return;
         }
@@ -489,7 +486,7 @@
      *
      * @param values The set of values, per property, being animated between.
      */
-    public void setValues(@NonNull PropertyValuesHolder... values) {
+    public void setValues(PropertyValuesHolder @NonNull ... values) {
         int numValues = values.length;
         mValues = values;
         mValuesMap = new HashMap<String, PropertyValuesHolder>(numValues);
@@ -510,8 +507,7 @@
      * values, per property, that define the animation.
      */
     @SuppressLint("ArrayReturn") /* Platform API */
-    @NonNull
-    public PropertyValuesHolder[] getValues() {
+    public PropertyValuesHolder @NonNull [] getValues() {
         return mValues;
     }
 
@@ -546,8 +542,7 @@
      * duration, as in <code>ValueAnimator.ofInt(0, 10).setDuration(500).start()</code>.
      */
     @Override
-    @NonNull
-    public ValueAnimator setDuration(long duration) {
+    public @NonNull ValueAnimator setDuration(long duration) {
         if (duration < 0) {
             throw new IllegalArgumentException("Animators cannot have negative duration: "
                     + duration);
@@ -813,8 +808,7 @@
      * (specified by several PropertyValuesHolder objects in the constructor), this function
      * returns the animated value for the first of those objects.
      */
-    @NonNull
-    public Object getAnimatedValue() {
+    public @NonNull Object getAnimatedValue() {
         if (mValues != null && mValues.length > 0) {
             return mValues[0].getAnimatedValue();
         }
@@ -832,8 +826,7 @@
      * @return animatedValue The value most recently calculated for the named property
      * by this <code>ValueAnimator</code>.
      */
-    @Nullable
-    public Object getAnimatedValue(@NonNull String propertyName) {
+    public @Nullable Object getAnimatedValue(@NonNull String propertyName) {
         PropertyValuesHolder valuesHolder = mValuesMap.get(propertyName);
         if (valuesHolder != null) {
             return valuesHolder.getAnimatedValue();
@@ -909,8 +902,7 @@
      * @return The timing interpolator for this ValueAnimator.
      */
     @Override
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         return mInterpolator;
     }
 
@@ -1195,8 +1187,7 @@
     /**
      * Returns the name of this animator for debugging purposes.
      */
-    @NonNull
-    public String getNameForTrace() {
+    public @NonNull String getNameForTrace() {
         return mAnimTraceName == null ? "animator" : mAnimTraceName;
     }
 
@@ -1458,8 +1449,7 @@
 
     @SuppressLint("NoClone") /* Platform API */
     @Override
-    @NonNull
-    public ValueAnimator clone() {
+    public @NonNull ValueAnimator clone() {
         final ValueAnimator anim = (ValueAnimator) super.clone();
         if (mUpdateListeners != null) {
             anim.mUpdateListeners = new ArrayList<AnimatorUpdateListener>(mUpdateListeners);
@@ -1507,9 +1497,8 @@
         return AnimationHandler.getAnimationCount();
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         String returnVal = "ValueAnimator@" + Integer.toHexString(hashCode());
         if (mValues != null) {
             for (int i = 0; i < mValues.length; ++i) {
diff --git a/core/core-appdigest/build.gradle b/core/core-appdigest/build.gradle
index efab216..4f26e39 100644
--- a/core/core-appdigest/build.gradle
+++ b/core/core-appdigest/build.gradle
@@ -29,6 +29,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     implementation("androidx.core:core:1.7.0")
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
@@ -46,8 +47,6 @@
     mavenVersion = LibraryVersions.CORE_APPDIGEST
     inceptionYear = "2020"
     description = "AndroidX AppDigest Library"
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/core/core-appdigest/src/androidTest/java/androidx/core/appdigest/ChecksumsTest.java b/core/core-appdigest/src/androidTest/java/androidx/core/appdigest/ChecksumsTest.java
index 82448fb..b0f208f 100644
--- a/core/core-appdigest/src/androidTest/java/androidx/core/appdigest/ChecksumsTest.java
+++ b/core/core-appdigest/src/androidTest/java/androidx/core/appdigest/ChecksumsTest.java
@@ -49,8 +49,6 @@
 import android.os.Build;
 import android.os.ParcelFileDescriptor;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.concurrent.futures.ResolvableFuture;
 import androidx.test.core.app.ApplicationProvider;
@@ -60,6 +58,8 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Assume;
@@ -225,8 +225,7 @@
         executeShellCommand("pm uninstall " + packageName);
     }
 
-    @NonNull
-    private static String bytesToHexString(byte[] array) {
+    private static @NonNull String bytesToHexString(byte[] array) {
         int offset = 0;
         int length = array.length;
 
@@ -243,8 +242,7 @@
         return new String(buf);
     }
 
-    @NonNull
-    private static byte[] hexStringToBytes(String s) {
+    private static byte @NonNull [] hexStringToBytes(String s) {
         int len = s.length();
         byte[] data = new byte[len / 2];
         for (int i = 0; i < len; i += 2) {
@@ -1159,7 +1157,7 @@
                         new android.content.pm.Checksum(android.content.pm.Checksum.TYPE_WHOLE_MD5,
                                 hexStringToBytes(TEST_FIXED_APK_MD5))};
 
-        static @NonNull android.content.pm.Checksum readFromStream(@NonNull DataInputStream dis)
+        static android.content.pm.@NonNull Checksum readFromStream(@NonNull DataInputStream dis)
                 throws IOException {
             final int type = dis.readInt();
 
@@ -1169,7 +1167,7 @@
         }
 
         private static void writeToStream(@NonNull DataOutputStream dos,
-                @NonNull android.content.pm.Checksum checksum) throws IOException {
+                android.content.pm.@NonNull Checksum checksum) throws IOException {
             dos.writeInt(checksum.getType());
 
             final byte[] valueBytes = checksum.getValue();
diff --git a/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksum.java b/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksum.java
index 47fa0ca..84674c9 100644
--- a/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksum.java
+++ b/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksum.java
@@ -19,11 +19,12 @@
 import static androidx.annotation.RestrictTo.Scope.LIBRARY;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.lang.annotation.Retention;
@@ -139,7 +140,7 @@
     /**
      * Checksum value.
      */
-    private final @NonNull byte[] mValue;
+    private final byte @NonNull [] mValue;
     /**
      * For Installer-provided checksums, package name of the Installer.
      */
@@ -147,20 +148,20 @@
     /**
      * For Installer-provided checksums, certificate of the Installer.
      */
-    private final @Nullable byte[] mInstallerCertificate;
+    private final byte @Nullable [] mInstallerCertificate;
 
     /**
      * Constructor, internal use only.
      */
     Checksum(@Nullable String splitName, @Checksum.Type int type,
-            @NonNull byte[] value) {
+            byte @NonNull [] value) {
         this(splitName, type, value, (String) null, (byte[]) null);
     }
 
     /**
      * Constructor, internal use only.
      */
-    Checksum(@Nullable String splitName, @Checksum.Type int type, @NonNull byte[] value,
+    Checksum(@Nullable String splitName, @Checksum.Type int type, byte @NonNull [] value,
             @Nullable String installerPackageName, @Nullable Certificate installerCertificate)
             throws CertificateEncodingException {
         this(splitName, type, value, installerPackageName,
@@ -184,9 +185,9 @@
     Checksum(
             @Nullable String splitName,
             @Type int type,
-            @NonNull byte[] value,
+            byte @NonNull [] value,
             @Nullable String installerPackageName,
-            @Nullable byte[] installerCertificate) {
+            byte @Nullable [] installerCertificate) {
         Preconditions.checkNotNull(value);
         this.mSplitName = splitName;
         this.mType = type;
@@ -219,7 +220,7 @@
     /**
      * Checksum value.
      */
-    public @NonNull byte[] getValue() {
+    public byte @NonNull [] getValue() {
         return mValue;
     }
 
diff --git a/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksums.java b/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksums.java
index dbb0098..720bf2c 100644
--- a/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksums.java
+++ b/core/core-appdigest/src/main/java/androidx/core/appdigest/Checksums.java
@@ -31,13 +31,14 @@
 import android.util.Pair;
 import android.util.SparseArray;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.concurrent.futures.ResolvableFuture;
 import androidx.core.util.Preconditions;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
diff --git a/core/core-appdigest/src/main/java/androidx/core/appdigest/ChecksumsApiSImpl.java b/core/core-appdigest/src/main/java/androidx/core/appdigest/ChecksumsApiSImpl.java
index 6106c1d..f2f5fb0 100644
--- a/core/core-appdigest/src/main/java/androidx/core/appdigest/ChecksumsApiSImpl.java
+++ b/core/core-appdigest/src/main/java/androidx/core/appdigest/ChecksumsApiSImpl.java
@@ -29,8 +29,6 @@
 import android.util.Log;
 import android.util.SparseArray;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.concurrent.futures.ResolvableFuture;
 
@@ -44,6 +42,8 @@
 import org.bouncycastle.cms.SignerInformationStore;
 import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
 import org.bouncycastle.operator.OperatorCreationException;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -282,7 +282,7 @@
         }
     }
 
-    private static @NonNull android.content.pm.Checksum readFromStream(@NonNull DataInputStream dis)
+    private static android.content.pm.@NonNull Checksum readFromStream(@NonNull DataInputStream dis)
             throws IOException {
         final int type = dis.readInt();
 
@@ -301,7 +301,7 @@
     }
 
     private static void writeToStream(@NonNull DataOutputStream dos,
-            @NonNull android.content.pm.Checksum checksum) throws IOException {
+            android.content.pm.@NonNull Checksum checksum) throws IOException {
         dos.writeInt(checksum.getType());
 
         final byte[] valueBytes = checksum.getValue();
@@ -339,7 +339,7 @@
      * @param signature detached PKCS7 signature in DER format
      * @return all certificates that passed verification
      */
-    private static @NonNull Certificate[] verifySignature(android.content.pm.Checksum[] checksums,
+    private static Certificate @NonNull [] verifySignature(android.content.pm.Checksum[] checksums,
             byte[] signature) throws NoSuchAlgorithmException, IOException, SignatureException {
         final byte[] blob;
         try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
diff --git a/core/core-appdigest/src/main/java/androidx/core/appdigest/VerityTreeBuilder.java b/core/core-appdigest/src/main/java/androidx/core/appdigest/VerityTreeBuilder.java
index 37870ca..6c8a379 100644
--- a/core/core-appdigest/src/main/java/androidx/core/appdigest/VerityTreeBuilder.java
+++ b/core/core-appdigest/src/main/java/androidx/core/appdigest/VerityTreeBuilder.java
@@ -16,7 +16,7 @@
 
 package androidx.core.appdigest;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.io.IOException;
 import java.io.RandomAccessFile;
diff --git a/core/core-backported-fixes/integration-tests/testapp/build.gradle b/core/core-backported-fixes/integration-tests/testapp/build.gradle
index 55fe60c..e336fb3 100644
--- a/core/core-backported-fixes/integration-tests/testapp/build.gradle
+++ b/core/core-backported-fixes/integration-tests/testapp/build.gradle
@@ -25,8 +25,8 @@
     api(libs.kotlinStdlib)
     implementation(project(":core:core-backported-fixes"))
 
-    implementation project(":activity:activity-compose")
-    implementation project(":activity:activity")
+    implementation(project(":activity:activity-compose"))
+    implementation(project(":activity:activity"))
     implementation(project(":core:core-ktx"))
     implementation(project(":compose:material:material"))
     implementation(project(":compose:material3:material3"))
diff --git a/core/core-backported-fixes/src/main/java/androidx/core/backported/fixes/KnownIssue.kt b/core/core-backported-fixes/src/main/java/androidx/core/backported/fixes/KnownIssue.kt
index 1de0d63..fa82720 100644
--- a/core/core-backported-fixes/src/main/java/androidx/core/backported/fixes/KnownIssue.kt
+++ b/core/core-backported-fixes/src/main/java/androidx/core/backported/fixes/KnownIssue.kt
@@ -17,9 +17,31 @@
 package androidx.core.backported.fixes
 
 /** List of all known issue reportable by [BackportedFixManager] */
-internal class KnownIssue private constructor(val id: Long, val alias: Int) {
+internal class KnownIssue
+private constructor(
+
+    /**
+     * The public id of this issue in the [Google Issue Tracker](https://issuetracker.google.com)
+     */
+    val id: Long,
+    /**
+     * The alias for this issue, if one exists.
+     *
+     * Known issues can have at most one alias.
+     *
+     * The value 0 indicates there is no alias for this issue. Non-zero alias values are unique
+     * across all known issues.
+     */
+    val alias: Int
+) {
     // TODO b/381266031 - Make public
     // TODO b/381267367 - Add link to public list issues
+
+    /**
+     * The url to the [Google Issue Tracker](https://issuetracker.google.com) for this known issue.
+     */
+    internal val url = "https://issuetracker.google.com/issues/$id"
+
     override fun equals(other: Any?) = other is KnownIssue && id == other.id
 
     override fun hashCode() = id.hashCode()
diff --git a/core/core-i18n/src/androidTest/java/androidx/core/i18n/DateTimeFormatterTest.kt b/core/core-i18n/src/androidTest/java/androidx/core/i18n/DateTimeFormatterTest.kt
index 26dc597..da8a883 100644
--- a/core/core-i18n/src/androidTest/java/androidx/core/i18n/DateTimeFormatterTest.kt
+++ b/core/core-i18n/src/androidTest/java/androidx/core/i18n/DateTimeFormatterTest.kt
@@ -16,7 +16,6 @@
 
 package androidx.core.i18n
 
-import android.annotation.SuppressLint
 import android.os.Build
 import android.util.Log
 import androidx.core.i18n.DateTimeFormatterSkeletonOptions as SkeletonOptions
@@ -40,7 +39,6 @@
 
 /** Must execute on an Android device. */
 @RunWith(AndroidJUnit4::class)
-@SuppressLint("ClassVerificationFailure")
 class DateTimeFormatterTest {
     companion object {
         // Lollipop introduced Locale.toLanguageTag and Locale.forLanguageTag
diff --git a/core/core-ktx/src/main/java/androidx/core/graphics/Bitmap.kt b/core/core-ktx/src/main/java/androidx/core/graphics/Bitmap.kt
index 1a34565..2bd6c43 100644
--- a/core/core-ktx/src/main/java/androidx/core/graphics/Bitmap.kt
+++ b/core/core-ktx/src/main/java/androidx/core/graphics/Bitmap.kt
@@ -18,7 +18,6 @@
 
 package androidx.core.graphics
 
-import android.annotation.SuppressLint
 import android.graphics.Bitmap
 import android.graphics.Canvas
 import android.graphics.ColorSpace
@@ -101,7 +100,6 @@
  * @param colorSpace The new bitmap's color space
  * @return A new bitmap with the specified dimensions and config
  */
-@SuppressLint("ClassVerificationFailure") // Inline fun
 @RequiresApi(26)
 public inline fun createBitmap(
     width: Int,
diff --git a/core/core-ktx/src/main/java/androidx/core/graphics/Color.kt b/core/core-ktx/src/main/java/androidx/core/graphics/Color.kt
index 83375f2b..b7336c2 100644
--- a/core/core-ktx/src/main/java/androidx/core/graphics/Color.kt
+++ b/core/core-ktx/src/main/java/androidx/core/graphics/Color.kt
@@ -18,7 +18,6 @@
 
 package androidx.core.graphics
 
-import android.annotation.SuppressLint
 import android.graphics.Color
 import android.graphics.ColorSpace
 import androidx.annotation.ColorInt
@@ -34,9 +33,7 @@
  * val (red, green, blue) = myColor
  * ```
  */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline operator fun Color.component1(): Float = getComponent(0)
+@RequiresApi(26) public inline operator fun Color.component1(): Float = getComponent(0)
 
 /**
  * Returns the second component of the color. For instance, when the color model of the color is
@@ -47,9 +44,7 @@
  * val (red, green, blue) = myColor
  * ```
  */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline operator fun Color.component2(): Float = getComponent(1)
+@RequiresApi(26) public inline operator fun Color.component2(): Float = getComponent(1)
 
 /**
  * Returns the third component of the color. For instance, when the color model of the color is
@@ -59,9 +54,7 @@
  * val (red, green, blue) = myColor
  * ```
  */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline operator fun Color.component3(): Float = getComponent(2)
+@RequiresApi(26) public inline operator fun Color.component3(): Float = getComponent(2)
 
 /**
  * Returns the fourth component of the color. For instance, when the color model of the color is
@@ -72,9 +65,7 @@
  * val (red, green, blue, alpha) = myColor
  * ```
  */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline operator fun Color.component4(): Float = getComponent(3)
+@RequiresApi(26) public inline operator fun Color.component4(): Float = getComponent(3)
 
 /**
  * Composites two translucent colors together. More specifically, adds two colors using the
@@ -93,7 +84,6 @@
  *   colors do not match
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public operator fun Color.plus(c: Color): Color = ColorUtils.compositeColors(c, this)
 
 /**
@@ -189,7 +179,6 @@
  * relative luminance defined in WCAG 2.0, W3C Recommendation 11 December 2008.
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorInt Int.luminance: Float
     get() = Color.luminance(this)
 
@@ -197,16 +186,13 @@
  * Creates a new [Color] instance from a color int. The resulting color is in the
  * [sRGB][android.graphics.ColorSpace.Named.SRGB] color space.
  */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline fun @receiver:ColorInt Int.toColor(): Color = Color.valueOf(this)
+@RequiresApi(26) public inline fun @receiver:ColorInt Int.toColor(): Color = Color.valueOf(this)
 
 /**
  * Converts the specified ARGB [color int][Color] to an RGBA [color long][Color] in the
  * [sRGB][android.graphics.ColorSpace.Named.SRGB] color space.
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorLong
 public inline fun @receiver:ColorInt Int.toColorLong(): Long = Color.pack(this)
 
@@ -220,7 +206,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline operator fun @receiver:ColorLong Long.component1(): Float = Color.red(this)
 
 /**
@@ -233,7 +218,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline operator fun @receiver:ColorLong Long.component2(): Float = Color.green(this)
 
 /**
@@ -246,7 +230,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline operator fun @receiver:ColorLong Long.component3(): Float = Color.blue(this)
 
 /**
@@ -259,7 +242,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline operator fun @receiver:ColorLong Long.component4(): Float = Color.alpha(this)
 
 /**
@@ -269,7 +251,6 @@
  * ```
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.alpha: Float
     get() = Color.alpha(this)
 
@@ -280,7 +261,6 @@
  * ```
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.red: Float
     get() = Color.red(this)
 
@@ -291,7 +271,6 @@
  * ```
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.green: Float
     get() = Color.green(this)
 
@@ -302,7 +281,6 @@
  * ```
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.blue: Float
     get() = Color.blue(this)
 
@@ -311,18 +289,14 @@
  * WCAG 2.0, W3C Recommendation 11 December 2008.
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.luminance: Float
     get() = Color.luminance(this)
 
 /** Creates a new [Color] instance from a [color long][Color]. */
-@RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
-public inline fun @receiver:ColorLong Long.toColor(): Color = Color.valueOf(this)
+@RequiresApi(26) public inline fun @receiver:ColorLong Long.toColor(): Color = Color.valueOf(this)
 
 /** Converts the specified [color long][Color] to an ARGB [color int][Color]. */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorInt
 public inline fun @receiver:ColorLong Long.toColorInt(): Int = Color.toArgb(this)
 
@@ -330,19 +304,16 @@
  * Indicates whether the color is in the [sRGB][android.graphics.ColorSpace.Named.SRGB] color space.
  */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.isSrgb: Boolean
     get() = Color.isSrgb(this)
 
 /** Indicates whether the color is in a [wide-gamut][android.graphics.ColorSpace] color space. */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.isWideGamut: Boolean
     get() = Color.isWideGamut(this)
 
 /** Returns the color space encoded in the specified color long. */
 @get:RequiresApi(26)
-@get:SuppressLint("ClassVerificationFailure")
 public inline val @receiver:ColorLong Long.colorSpace: ColorSpace
     get() = Color.colorSpace(this)
 
@@ -354,7 +325,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorLong
 public inline infix fun @receiver:ColorInt Int.convertTo(colorSpace: ColorSpace.Named): Long =
     Color.convert(this, ColorSpace.get(colorSpace))
@@ -367,7 +337,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorLong
 public inline infix fun @receiver:ColorInt Int.convertTo(colorSpace: ColorSpace): Long =
     Color.convert(this, colorSpace)
@@ -380,7 +349,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorLong
 public inline infix fun @receiver:ColorLong Long.convertTo(colorSpace: ColorSpace.Named): Long =
     Color.convert(this, ColorSpace.get(colorSpace))
@@ -393,7 +361,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 @ColorLong
 public inline infix fun @receiver:ColorLong Long.convertTo(colorSpace: ColorSpace): Long =
     Color.convert(this, colorSpace)
@@ -406,7 +373,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline infix fun Color.convertTo(colorSpace: ColorSpace.Named): Color =
     convert(ColorSpace.get(colorSpace))
 
@@ -418,7 +384,6 @@
  * ```
  */
 @RequiresApi(26)
-@SuppressLint("ClassVerificationFailure")
 public inline infix fun Color.convertTo(colorSpace: ColorSpace): Color = convert(colorSpace)
 
 /**
diff --git a/core/core-ktx/src/main/java/androidx/core/graphics/Path.kt b/core/core-ktx/src/main/java/androidx/core/graphics/Path.kt
index cc7cce0..099008f 100644
--- a/core/core-ktx/src/main/java/androidx/core/graphics/Path.kt
+++ b/core/core-ktx/src/main/java/androidx/core/graphics/Path.kt
@@ -14,12 +14,10 @@
  * limitations under the License.
  */
 
-@file:SuppressLint("ClassVerificationFailure") // Entire file is RequiresApi(19)
 @file:Suppress("NOTHING_TO_INLINE")
 
 package androidx.core.graphics
 
-import android.annotation.SuppressLint
 import android.graphics.Path
 import androidx.annotation.RequiresApi
 
diff --git a/core/core-ktx/src/main/java/androidx/core/graphics/drawable/ColorDrawable.kt b/core/core-ktx/src/main/java/androidx/core/graphics/drawable/ColorDrawable.kt
index bfdfd55..9d0afc0 100644
--- a/core/core-ktx/src/main/java/androidx/core/graphics/drawable/ColorDrawable.kt
+++ b/core/core-ktx/src/main/java/androidx/core/graphics/drawable/ColorDrawable.kt
@@ -18,7 +18,6 @@
 
 package androidx.core.graphics.drawable
 
-import android.annotation.SuppressLint
 import android.graphics.Color
 import android.graphics.drawable.ColorDrawable
 import androidx.annotation.ColorInt
@@ -28,6 +27,4 @@
 public inline fun @receiver:ColorInt Int.toDrawable(): ColorDrawable = ColorDrawable(this)
 
 /** Create a [ColorDrawable] from this [Color] (via [Color.toArgb]). */
-@SuppressLint("ClassVerificationFailure") // Inline fun
-@RequiresApi(26)
-public inline fun Color.toDrawable(): ColorDrawable = ColorDrawable(toArgb())
+@RequiresApi(26) public inline fun Color.toDrawable(): ColorDrawable = ColorDrawable(toArgb())
diff --git a/core/core-ktx/src/main/java/androidx/core/graphics/drawable/Icon.kt b/core/core-ktx/src/main/java/androidx/core/graphics/drawable/Icon.kt
index 3f48a14..ca78fda 100644
--- a/core/core-ktx/src/main/java/androidx/core/graphics/drawable/Icon.kt
+++ b/core/core-ktx/src/main/java/androidx/core/graphics/drawable/Icon.kt
@@ -14,12 +14,10 @@
  * limitations under the License.
  */
 
-@file:SuppressLint("ClassVerificationFailure") // Entire file is RequiresApi(26)
 @file:Suppress("NOTHING_TO_INLINE") // Aliases to public API.
 
 package androidx.core.graphics.drawable
 
-import android.annotation.SuppressLint
 import android.graphics.Bitmap
 import android.graphics.drawable.Icon
 import android.net.Uri
diff --git a/core/core-ktx/src/main/java/androidx/core/util/Half.kt b/core/core-ktx/src/main/java/androidx/core/util/Half.kt
index 637b36e..2cd1972 100644
--- a/core/core-ktx/src/main/java/androidx/core/util/Half.kt
+++ b/core/core-ktx/src/main/java/androidx/core/util/Half.kt
@@ -14,12 +14,10 @@
  * limitations under the License.
  */
 
-@file:SuppressLint("ClassVerificationFailure") // Entire file is RequiresApi(26)
 @file:Suppress("NOTHING_TO_INLINE") // Aliases to other public API.
 
 package androidx.core.util
 
-import android.annotation.SuppressLint
 import android.util.Half
 import androidx.annotation.HalfFloat
 import androidx.annotation.RequiresApi
diff --git a/core/core-ktx/src/main/java/androidx/core/util/Range.kt b/core/core-ktx/src/main/java/androidx/core/util/Range.kt
index 3b19652..b09bd62 100644
--- a/core/core-ktx/src/main/java/androidx/core/util/Range.kt
+++ b/core/core-ktx/src/main/java/androidx/core/util/Range.kt
@@ -14,12 +14,10 @@
  * limitations under the License.
  */
 
-@file:SuppressLint("ClassVerificationFailure") // Entire file is RequiresApi(21)
 @file:Suppress("NOTHING_TO_INLINE") // Aliases to public API.
 
 package androidx.core.util
 
-import android.annotation.SuppressLint
 import android.util.Range
 import androidx.annotation.RequiresApi
 
diff --git a/core/core-ktx/src/main/java/androidx/core/util/Size.kt b/core/core-ktx/src/main/java/androidx/core/util/Size.kt
index 69d2da2..017c840 100644
--- a/core/core-ktx/src/main/java/androidx/core/util/Size.kt
+++ b/core/core-ktx/src/main/java/androidx/core/util/Size.kt
@@ -14,12 +14,10 @@
  * limitations under the License.
  */
 
-@file:SuppressLint("ClassVerificationFailure") // Entire file is RequiresApi(21)
 @file:Suppress("NOTHING_TO_INLINE")
 
 package androidx.core.util
 
-import android.annotation.SuppressLint
 import android.util.Size
 import android.util.SizeF
 import androidx.annotation.RequiresApi
diff --git a/core/core-location-altitude/build.gradle b/core/core-location-altitude/build.gradle
index da153cb..681776b 100644
--- a/core/core-location-altitude/build.gradle
+++ b/core/core-location-altitude/build.gradle
@@ -40,16 +40,17 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api(libs.kotlinStdlib)
     api("androidx.annotation:annotation:1.8.1")
 
     implementation(project(":core:core-location-altitude-proto"))
     implementation(libs.autoValueAnnotations)
     implementation("androidx.core:core:1.13.0")
-    implementation("androidx.room:room-runtime:2.4.3")
+    implementation(project(":room:room-runtime"))
 
     annotationProcessor(libs.autoValue)
-    annotationProcessor("androidx.room:room-compiler:2.4.3")
+    annotationProcessor(project(":room:room-compiler"))
 
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.testCore)
@@ -64,6 +65,4 @@
     mavenVersion = LibraryVersions.CORE_LOCATION_ALTITUDE
     inceptionYear = "2022"
     description = "Provides compatibility APIs concerning location altitudes."
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/AltitudeConverterCompat.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/AltitudeConverterCompat.java
index c172d59..01713e6 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/AltitudeConverterCompat.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/AltitudeConverterCompat.java
@@ -21,12 +21,13 @@
 import android.os.Build;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.WorkerThread;
 import androidx.core.location.altitude.impl.AltitudeConverter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.IOException;
 
 /**
@@ -46,8 +47,7 @@
     private static final Object sLock = new Object();
 
     @GuardedBy("sLock")
-    @Nullable
-    private static AltitudeConverter sAltitudeConverter;
+    private static @Nullable AltitudeConverter sAltitudeConverter;
 
     /** Prevents instantiation. */
     private AltitudeConverterCompat() {
@@ -91,8 +91,7 @@
         private static final Object sLock = new Object();
 
         @GuardedBy("sLock")
-        @Nullable
-        private static Object sAltitudeConverter;
+        private static @Nullable Object sAltitudeConverter;
 
         /** Prevents instantiation. */
         private Api34Impl() {
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/AltitudeConverter.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/AltitudeConverter.java
index 3fd1272..42d805b 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/AltitudeConverter.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/AltitudeConverter.java
@@ -19,11 +19,12 @@
 import android.content.Context;
 import android.location.Location;
 
-import androidx.annotation.NonNull;
 import androidx.core.location.LocationCompat;
 import androidx.core.location.altitude.impl.proto.MapParamsProto;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.IOException;
 
 /** Implements {@link androidx.core.location.altitude.AltitudeConverterCompat}. */
@@ -80,8 +81,7 @@
      * 2023 IEEE/ION Position, Location and Navigation Symposium (PLANS).
      * </pre>
      */
-    @NonNull
-    private static long[] findMapSquare(@NonNull MapParamsProto params,
+    private static long @NonNull [] findMapSquare(@NonNull MapParamsProto params,
             @NonNull Location location) {
         long s2CellId =
                 S2CellIdUtils.fromLatLngDegrees(location.getLatitude(), location.getLongitude());
@@ -143,7 +143,7 @@
      * such a common neighbor does not exist, returns z11.
      */
     private static long findCommonNeighbor(
-            @NonNull long[] edgeNeighbors, @NonNull long[] otherEdgeNeighbors, long z11) {
+            long @NonNull [] edgeNeighbors, long @NonNull [] otherEdgeNeighbors, long z11) {
         for (long edgeNeighbor : edgeNeighbors) {
             if (edgeNeighbor == z11) {
                 continue;
@@ -163,7 +163,7 @@
      * accuracy; otherwise, does not add a corresponding accuracy.
      */
     private static void addMslAltitude(@NonNull MapParamsProto params,
-            @NonNull double[] geoidHeightsMeters, @NonNull Location location) {
+            double @NonNull [] geoidHeightsMeters, @NonNull Location location) {
         double h0 = geoidHeightsMeters[0];
         double h1 = geoidHeightsMeters[1];
         double h2 = geoidHeightsMeters[2];
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/GeoidHeightMap.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/GeoidHeightMap.java
index 811a19f..cdaf821 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/GeoidHeightMap.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/GeoidHeightMap.java
@@ -22,8 +22,6 @@
 import android.util.LruCache;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.location.altitude.impl.db.AltitudeConverterDatabase;
 import androidx.core.location.altitude.impl.db.MapParamsEntity;
 import androidx.core.location.altitude.impl.db.TilesEntity;
@@ -33,6 +31,9 @@
 import androidx.core.util.Preconditions;
 import androidx.room.Room;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.ByteBuffer;
@@ -58,20 +59,17 @@
     private static final Object sLock = new Object();
 
     @GuardedBy("sLock")
-    @Nullable
-    private static MapParamsProto sParams;
+    private static @Nullable MapParamsProto sParams;
 
     /** Defines the resource database for {@link AltitudeConverter}. */
     @GuardedBy("sLock")
-    @Nullable
-    private static AltitudeConverterDatabase sDatabase;
+    private static @Nullable AltitudeConverterDatabase sDatabase;
 
 
     /** Defines a cache large enough to hold all cache tiles needed for interpolation. */
     private final LruCache<Long, S2TileProto> mCacheTiles = new LruCache<>(4);
 
-    @NonNull
-    public static AltitudeConverterDatabase getDatabase(@NonNull Context context) {
+    public static @NonNull AltitudeConverterDatabase getDatabase(@NonNull Context context) {
         synchronized (sLock) {
             if (sDatabase == null) {
                 sDatabase = Room.databaseBuilder(context.getApplicationContext(),
@@ -86,8 +84,7 @@
      * Returns the singleton parameter instance for a spherically projected geoid height map and its
      * corresponding tile management.
      */
-    @NonNull
-    public static MapParamsProto getParams(@NonNull Context context) throws IOException {
+    public static @NonNull MapParamsProto getParams(@NonNull Context context) throws IOException {
         synchronized (sLock) {
             if (sParams == null) {
                 MapParamsEntity current = getDatabase(context).mapParamsDao().getCurrent();
@@ -104,8 +101,7 @@
         return S2CellIdUtils.getParent(s2CellId, params.getCacheTileS2Level());
     }
 
-    @NonNull
-    private static String getDiskToken(@NonNull MapParamsProto params, long s2CellId) {
+    private static @NonNull String getDiskToken(@NonNull MapParamsProto params, long s2CellId) {
         return S2CellIdUtils.getToken(
                 S2CellIdUtils.getParent(s2CellId, params.getDiskTileS2Level()));
     }
@@ -117,7 +113,7 @@
      */
     private static boolean getUnitIntervalValues(@NonNull MapParamsProto params,
             @NonNull TileFunction tileFunction,
-            @NonNull long[] s2CellIds, @NonNull double[] values) throws IOException {
+            long @NonNull [] s2CellIds, double @NonNull [] values) throws IOException {
         int len = s2CellIds.length;
 
         S2TileProto[] tiles = new S2TileProto[len];
@@ -150,9 +146,9 @@
 
     @SuppressWarnings("ReferenceEquality")
     private static void mergeByteBufferValues(@NonNull MapParamsProto params,
-            @NonNull long[] s2CellIds,
-            @NonNull S2TileProto[] tiles,
-            int tileIndex, @NonNull double[] values) {
+            long @NonNull [] s2CellIds,
+            S2TileProto @NonNull [] tiles,
+            int tileIndex, double @NonNull [] values) {
         ByteString byteString = tiles[tileIndex].getByteBuffer();
         if (byteString.isEmpty()) {
             return;
@@ -177,17 +173,17 @@
     }
 
     private static void mergeByteJpegValues(@NonNull MapParamsProto params,
-            @NonNull long[] s2CellIds,
-            @NonNull S2TileProto[] tiles,
-            int tileIndex, @NonNull double[] values) throws IOException {
+            long @NonNull [] s2CellIds,
+            S2TileProto @NonNull [] tiles,
+            int tileIndex, double @NonNull [] values) throws IOException {
         mergeByteImageValues(params, tiles[tileIndex].getByteJpeg(), s2CellIds, tiles, tileIndex,
                 values);
     }
 
     private static void mergeBytePngValues(@NonNull MapParamsProto params,
-            @NonNull long[] s2CellIds,
-            @NonNull S2TileProto[] tiles,
-            int tileIndex, @NonNull double[] values) throws IOException {
+            long @NonNull [] s2CellIds,
+            S2TileProto @NonNull [] tiles,
+            int tileIndex, double @NonNull [] values) throws IOException {
         mergeByteImageValues(params, tiles[tileIndex].getBytePng(), s2CellIds, tiles, tileIndex,
                 values);
     }
@@ -195,8 +191,8 @@
     @SuppressWarnings("ReferenceEquality")
     private static void mergeByteImageValues(@NonNull MapParamsProto params,
             @NonNull ByteString byteString,
-            @NonNull long[] s2CellIds,
-            @NonNull S2TileProto[] tiles, int tileIndex, @NonNull double[] values)
+            long @NonNull [] s2CellIds,
+            S2TileProto @NonNull [] tiles, int tileIndex, double @NonNull [] values)
             throws IOException {
         if (byteString.isEmpty()) {
             return;
@@ -238,7 +234,7 @@
      * Throws an {@link IllegalArgumentException} if the {@code s2CellIds} has an invalid length or
      * ID.
      */
-    private static void validate(@NonNull MapParamsProto params, @NonNull long[] s2CellIds) {
+    private static void validate(@NonNull MapParamsProto params, long @NonNull [] s2CellIds) {
         Preconditions.checkArgument(s2CellIds.length == 4);
         for (long s2CellId : s2CellIds) {
             Preconditions.checkArgument(S2CellIdUtils.getLevel(s2CellId) == params.getMapS2Level());
@@ -250,9 +246,8 @@
      * {@code s2CellIds}. Throws an {@link IOException} if a geoid height cannot be calculated for
      * an ID.
      */
-    @NonNull
-    public double[] readGeoidHeights(@NonNull MapParamsProto params, @NonNull Context context,
-            @NonNull long[] s2CellIds) throws IOException {
+    public double @NonNull [] readGeoidHeights(@NonNull MapParamsProto params,
+            @NonNull Context context, long @NonNull [] s2CellIds) throws IOException {
         validate(params, s2CellIds);
         double[] heightsMeters = new double[s2CellIds.length];
         if (getGeoidHeights(params, mCacheTiles::get, s2CellIds, heightsMeters)) {
@@ -271,9 +266,8 @@
      * be loaded from raw assets. Returns the heights if present for all IDs; otherwise, returns
      * null.
      */
-    @Nullable
-    public double[] readGeoidHeights(@NonNull MapParamsProto params, @NonNull long[] s2CellIds)
-            throws IOException {
+    public double @Nullable [] readGeoidHeights(@NonNull MapParamsProto params,
+            long @NonNull [] s2CellIds) throws IOException {
         validate(params, s2CellIds);
         double[] heightsMeters = new double[s2CellIds.length];
         if (getGeoidHeights(params, mCacheTiles::get, s2CellIds, heightsMeters)) {
@@ -288,8 +282,8 @@
      * returns false and adds NaNs for absent heights.
      */
     private boolean getGeoidHeights(@NonNull MapParamsProto params,
-            @NonNull TileFunction tileFunction, @NonNull long[] s2CellIds,
-            @NonNull double[] heightsMeters) throws IOException {
+            @NonNull TileFunction tileFunction, long @NonNull [] s2CellIds,
+            double @NonNull [] heightsMeters) throws IOException {
         boolean allFound = getUnitIntervalValues(params, tileFunction, s2CellIds, heightsMeters);
         for (int i = 0; i < heightsMeters.length; i++) {
             // NaNs are properly preserved.
@@ -299,9 +293,8 @@
         return allFound;
     }
 
-    @NonNull
-    private TileFunction loadFromCacheAndDisk(@NonNull MapParamsProto params,
-            @NonNull Context context, @NonNull long[] s2CellIds) throws IOException {
+    private @NonNull TileFunction loadFromCacheAndDisk(@NonNull MapParamsProto params,
+            @NonNull Context context, long @NonNull [] s2CellIds) throws IOException {
         int len = s2CellIds.length;
 
         // Enable batch loading by finding all cache keys upfront.
@@ -353,8 +346,8 @@
     }
 
     private void mergeFromDiskTile(@NonNull MapParamsProto params, @NonNull S2TileProto diskTile,
-            @NonNull long[] cacheKeys, @NonNull String[] diskTokens, int diskTokenIndex,
-            @NonNull S2TileProto[] loadedTiles) throws IOException {
+            long @NonNull [] cacheKeys, String @NonNull [] diskTokens, int diskTokenIndex,
+            S2TileProto @NonNull [] loadedTiles) throws IOException {
         int len = cacheKeys.length;
         int numMapCellsPerCacheTile =
                 1 << (2 * (params.getMapS2Level() - params.getCacheTileS2Level()));
@@ -404,7 +397,6 @@
     /** Defines a function-like object to retrieve tiles for cache keys. */
     private interface TileFunction {
 
-        @Nullable
-        S2TileProto getTile(long cacheKey);
+        @Nullable S2TileProto getTile(long cacheKey);
     }
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/S2CellIdUtils.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/S2CellIdUtils.java
index 1c09c33..d4d6233 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/S2CellIdUtils.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/S2CellIdUtils.java
@@ -16,7 +16,7 @@
 
 package androidx.core.location.altitude.impl;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.Arrays;
 import java.util.Locale;
@@ -117,7 +117,7 @@
      * <p>Inserts in the order of down, right, up, and left directions, in that order. All
      * neighbors are guaranteed to be distinct.
      */
-    static void getEdgeNeighbors(long s2CellId, @NonNull long[] neighbors) {
+    static void getEdgeNeighbors(long s2CellId, long @NonNull [] neighbors) {
         int level = getLevel(s2CellId);
         int size = levelToSizeIj(level);
         int face = getFace(s2CellId);
@@ -210,8 +210,7 @@
      * Encodes the S2 cell id to compact text strings suitable for display or indexing. Cells at
      * lower levels (i.e., larger cells) are encoded into fewer characters.
      */
-    @NonNull
-    static String getToken(long s2CellId) {
+    static @NonNull String getToken(long s2CellId) {
         if (s2CellId == 0) {
             return "X";
         }
@@ -425,8 +424,7 @@
         return 1.0 + machEps;
     }
 
-    @NonNull
-    private static UvTransform[] createUvTransforms() {
+    private static UvTransform @NonNull [] createUvTransforms() {
         UvTransform[] uvTransforms = new UvTransform[NUM_FACES];
         uvTransforms[0] =
                 new UvTransform() {
@@ -509,8 +507,7 @@
         return uvTransforms;
     }
 
-    @NonNull
-    private static XyzTransform[] createXyzTransforms() {
+    private static XyzTransform @NonNull [] createXyzTransforms() {
         XyzTransform[] xyzTransforms = new XyzTransform[NUM_FACES];
         xyzTransforms[0] =
                 new XyzTransform() {
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/AltitudeConverterDatabase.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/AltitudeConverterDatabase.java
index b84b467..eb8b383 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/AltitudeConverterDatabase.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/AltitudeConverterDatabase.java
@@ -16,22 +16,21 @@
 
 package androidx.core.location.altitude.impl.db;
 
-import androidx.annotation.NonNull;
 import androidx.core.location.altitude.impl.AltitudeConverter;
 import androidx.room.Database;
 import androidx.room.RoomDatabase;
 import androidx.room.TypeConverters;
 
+import org.jspecify.annotations.NonNull;
+
 /** Defines the resource database for {@link AltitudeConverter}. */
 @Database(entities = {MapParamsEntity.class, TilesEntity.class}, version = 1, exportSchema = false)
 @TypeConverters({MapParamsEntity.class, TilesEntity.class})
 public abstract class AltitudeConverterDatabase extends RoomDatabase {
 
     /** Returns the data access object for the MapParams table. */
-    @NonNull
-    public abstract MapParamsDao mapParamsDao();
+    public abstract @NonNull MapParamsDao mapParamsDao();
 
     /** Returns the data access object for the Tiles table. */
-    @NonNull
-    public abstract TilesDao tilesDao();
+    public abstract @NonNull TilesDao tilesDao();
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsDao.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsDao.java
index c5e18b8..927695d 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsDao.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsDao.java
@@ -16,16 +16,16 @@
 
 package androidx.core.location.altitude.impl.db;
 
-import androidx.annotation.Nullable;
 import androidx.room.Dao;
 import androidx.room.Query;
 
+import org.jspecify.annotations.Nullable;
+
 /** Provides data access for entities within the MapParams table. */
 @Dao
 public interface MapParamsDao {
 
     /** Returns the most current map parameters. */
-    @Nullable
     @Query("SELECT * FROM MapParams ORDER BY id DESC LIMIT 1")
-    MapParamsEntity getCurrent();
+    @Nullable MapParamsEntity getCurrent();
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsEntity.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsEntity.java
index 7b27678..7abe9f7 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsEntity.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/MapParamsEntity.java
@@ -18,8 +18,6 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.location.altitude.impl.proto.InvalidProtocolBufferException;
 import androidx.core.location.altitude.impl.proto.MapParamsProto;
 import androidx.room.ColumnInfo;
@@ -31,6 +29,8 @@
 import com.google.auto.value.AutoValue.CopyAnnotations;
 
 import org.jetbrains.annotations.Contract;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /** Defines the entity type and its converters within the MapParams table. */
 @AutoValue
@@ -39,23 +39,20 @@
 
     private static final String TAG = "MapParamsEntity";
 
-    @NonNull
     @Contract("_, _ -> new")
-    static MapParamsEntity create(int id, MapParamsProto value) {
+    static @NonNull MapParamsEntity create(int id, MapParamsProto value) {
         return new AutoValue_MapParamsEntity(id, value);
     }
 
     /** Encodes a {@link MapParamsProto} */
-    @NonNull
     @TypeConverter
-    public static byte[] fromValue(@NonNull MapParamsProto value) {
+    public static byte @NonNull [] fromValue(@NonNull MapParamsProto value) {
         return value.toByteArray();
     }
 
     /** Decodes a {@link MapParamsProto} */
-    @Nullable
     @TypeConverter
-    public static MapParamsProto toValue(@NonNull byte[] byteArray) {
+    public static @Nullable MapParamsProto toValue(byte @NonNull [] byteArray) {
         try {
             return MapParamsProto.parseFrom(byteArray);
         } catch (InvalidProtocolBufferException e) {
@@ -74,6 +71,5 @@
      */
     @CopyAnnotations
     @ColumnInfo(name = "value")
-    @NonNull
-    public abstract MapParamsProto value();
+    public abstract @NonNull MapParamsProto value();
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesDao.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesDao.java
index 719d171..fee5b3f 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesDao.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesDao.java
@@ -16,17 +16,17 @@
 
 package androidx.core.location.altitude.impl.db;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.room.Dao;
 import androidx.room.Query;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /** Provides data access for entities within the Tiles table. */
 @Dao
 public interface TilesDao {
 
     /** Returns the tile associated with the provided token. */
-    @Nullable
     @Query("SELECT * FROM Tiles WHERE token = :token LIMIT 1")
-    TilesEntity get(@NonNull String token);
+    @Nullable TilesEntity get(@NonNull String token);
 }
diff --git a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesEntity.java b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesEntity.java
index 6b3f29f..c2edb37 100644
--- a/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesEntity.java
+++ b/core/core-location-altitude/src/main/java/androidx/core/location/altitude/impl/db/TilesEntity.java
@@ -18,8 +18,6 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.location.altitude.impl.proto.InvalidProtocolBufferException;
 import androidx.core.location.altitude.impl.proto.S2TileProto;
 import androidx.room.ColumnInfo;
@@ -30,6 +28,9 @@
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.AutoValue.CopyAnnotations;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /** Defines the entity type and its converters within the Tiles table. */
 @AutoValue
 @Entity(tableName = "Tiles")
@@ -42,16 +43,14 @@
     }
 
     /** Encodes a {@link S2TileProto}. */
-    @NonNull
     @TypeConverter
-    public static byte[] fromTile(@NonNull S2TileProto tile) {
+    public static byte @NonNull [] fromTile(@NonNull S2TileProto tile) {
         return tile.toByteArray();
     }
 
     /** Decodes a {@link S2TileProto}. */
-    @Nullable
     @TypeConverter
-    public static S2TileProto toTile(@NonNull byte[] byteArray) {
+    public static @Nullable S2TileProto toTile(byte @NonNull [] byteArray) {
         try {
             return S2TileProto.parseFrom(byteArray);
         } catch (InvalidProtocolBufferException e) {
@@ -64,12 +63,10 @@
     @CopyAnnotations
     @PrimaryKey
     @ColumnInfo(name = "token")
-    @NonNull
-    public abstract String token();
+    public abstract @NonNull String token();
 
     /** Returns a tile within an S2 cell ID to unit interval map. */
     @CopyAnnotations
     @ColumnInfo(name = "tile")
-    @NonNull
-    public abstract S2TileProto tile();
+    public abstract @NonNull S2TileProto tile();
 }
diff --git a/core/core-telecom/integration-tests/testapp/src/main/AndroidManifest.xml b/core/core-telecom/integration-tests/testapp/src/main/AndroidManifest.xml
index 378517a..2f54068 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/core/core-telecom/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -20,6 +20,9 @@
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
     <!-- BLUETOOTH_CONNECT is needed in order to expose the BT device name -->
     <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <!-- required permissions in order to post call-style notifications -->
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
 
     <application
         android:icon="@drawable/ic_launcher"
@@ -28,6 +31,7 @@
         <activity
             android:name=".CallingMainActivity"
             android:exported="true"
+            android:launchMode="singleTask"
             android:label="@string/main_activity_name"
             android:theme="@style/Theme.AppCompat">
             <intent-filter>
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
index c47516a..9a52dc1 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
@@ -103,6 +103,7 @@
                     }
                     when (ItemsViewModel.callObject.mCallControl!!.setActive()) {
                         is CallControlResult.Success -> {
+                            ItemsViewModel.callObject.updateNotificationToOngoing()
                             holder.currentState.text = "CurrentState=[active]"
                         }
                         is CallControlResult.Error -> {
@@ -130,6 +131,7 @@
             holder.disconnectButton.setOnClickListener {
                 CoroutineScope(Dispatchers.IO).launch {
                     endAudioRecording()
+                    ItemsViewModel.callObject.clearNotification()
                     ItemsViewModel.callObject.mCallControl?.disconnect(
                         DisconnectCause(DisconnectCause.LOCAL)
                     )
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
index 6de2436..432f601 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
@@ -18,6 +18,9 @@
 
 import android.annotation.SuppressLint
 import android.app.Activity
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
 import android.os.Bundle
 import android.telecom.DisconnectCause
 import android.util.Log
@@ -31,11 +34,13 @@
 import androidx.core.telecom.CallEndpointCompat
 import androidx.core.telecom.CallsManager
 import androidx.core.telecom.extensions.RaiseHandState
-import androidx.core.telecom.test.Utilities.Companion.ALL_CALL_CAPABILITIES
-import androidx.core.telecom.test.Utilities.Companion.INCOMING_NAME
-import androidx.core.telecom.test.Utilities.Companion.INCOMING_URI
-import androidx.core.telecom.test.Utilities.Companion.OUTGOING_NAME
-import androidx.core.telecom.test.Utilities.Companion.OUTGOING_URI
+import androidx.core.telecom.test.Constants.Companion.ALL_CALL_CAPABILITIES
+import androidx.core.telecom.test.Constants.Companion.INCOMING_NAME
+import androidx.core.telecom.test.Constants.Companion.INCOMING_URI
+import androidx.core.telecom.test.Constants.Companion.OUTGOING_NAME
+import androidx.core.telecom.test.Constants.Companion.OUTGOING_URI
+import androidx.core.telecom.test.NotificationsUtilities.Companion.IS_ANSWER_ACTION
+import androidx.core.telecom.test.NotificationsUtilities.Companion.NOTIFICATION_CHANNEL_ID
 import androidx.core.telecom.util.ExperimentalAppActions
 import androidx.core.view.WindowCompat
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -45,6 +50,7 @@
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
@@ -55,29 +61,38 @@
     // Activity
     private val TAG = CallingMainActivity::class.simpleName
     private val mScope = CoroutineScope(Dispatchers.Default)
-    private var mCallCount: Int = 0
-
+    private lateinit var mContext: Context
+    private var mCurrentCallCount: Int = 0
     // Telecom
-    private var mCallsManager: CallsManager? = null
-
+    private lateinit var mCallsManager: CallsManager
     // Ongoing Call List
     private var mRecyclerView: RecyclerView? = null
     private var mCallObjects: ArrayList<CallRow> = ArrayList()
     private lateinit var mAdapter: CallListAdapter
-
     // Pre-Call Endpoint List
     private var mPreCallEndpointsRecyclerView: RecyclerView? = null
     private var mCurrentPreCallEndpoints: ArrayList<CallEndpointCompat> = arrayListOf()
     private lateinit var mPreCallEndpointAdapter: PreCallEndpointsAdapter
+    // Notification
+    private var mNextNotificationId: Int = 1
+    private lateinit var mNotificationManager: NotificationManager
+    private val mNotificationActionInfoFlow: MutableStateFlow<NotificationActionInfo> =
+        MutableStateFlow(NotificationActionInfo(-1, false))
+
+    /**
+     * NotificationActionInfo couples information propagated from the Call-Style notification on
+     * which action button was clicked (e.g. answer the call or decline ) *
+     */
+    data class NotificationActionInfo(val id: Int, val isAnswer: Boolean)
 
     override fun onCreate(savedInstanceState: Bundle?) {
+        Log.i(TAG, "onCreate")
         WindowCompat.setDecorFitsSystemWindows(window, false)
         super.onCreate(savedInstanceState)
-
         setContentView(R.layout.activity_main)
-
+        mContext = applicationContext
+        initNotifications(mContext)
         mCallsManager = CallsManager(this)
-        mCallCount = 0
 
         val raiseHandCheckBox = findViewById<CheckBox>(R.id.RaiseHandCheckbox)
         val kickParticipantCheckBox = findViewById<CheckBox>(R.id.KickPartCheckbox)
@@ -154,6 +169,23 @@
         mPreCallEndpointsRecyclerView?.adapter = mPreCallEndpointAdapter
     }
 
+    override fun onNewIntent(intent: Intent?) {
+        super.onNewIntent(intent)
+        Log.i(TAG, "onNewIntent: intent=[$intent]")
+        maybeHandleNotificationAction(intent)
+    }
+
+    private fun maybeHandleNotificationAction(intent: Intent?) {
+        if (intent != null) {
+            val id = intent.getIntExtra(NotificationsUtilities.NOTIFICATION_ID, -1)
+            if (id != -1) {
+                val isAnswer = intent.getBooleanExtra(IS_ANSWER_ACTION, false)
+                Log.i(TAG, "handleNotification: id=$id, isAnswer=$isAnswer")
+                mNotificationActionInfoFlow.value = NotificationActionInfo(id, isAnswer)
+            }
+        }
+    }
+
     override fun onDestroy() {
         super.onDestroy()
         for (call in mCallObjects) {
@@ -165,6 +197,12 @@
                 }
             }
         }
+        NotificationsUtilities.deleteNotificationChannel(mContext)
+    }
+
+    private fun initNotifications(c: Context) {
+        NotificationsUtilities.initNotificationChannel(c)
+        mNotificationManager = c.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
     }
 
     @SuppressLint("WrongConstant")
@@ -179,7 +217,7 @@
         if (streamingCheckBox.isChecked) {
             capabilities = capabilities or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING
         }
-        mCallsManager?.registerAppWithTelecom(capabilities)
+        mCallsManager.registerAppWithTelecom(capabilities)
     }
 
     private suspend fun addCallWithAttributes(
@@ -189,7 +227,8 @@
         isKickParticipantEnabled: Boolean
     ) {
         Log.i(TAG, "addCallWithAttributes: attributes=$attributes")
-        val callObject = VoipCall()
+        val callObject = VoipCall(this, attributes)
+        callObject.setNotificationId(mNextNotificationId++)
 
         try {
             val handler = CoroutineExceptionHandler { _, exception ->
@@ -209,28 +248,43 @@
                     }
                 } catch (e: Exception) {
                     logException(e, "addCallWithAttributes: catch inner")
+                    NotificationsUtilities.clearNotification(mContext, callObject.mNotificationId)
                 } finally {
                     Log.i(TAG, "addCallWithAttributes: finally block")
                 }
             }
         } catch (e: Exception) {
             logException(e, "addCallWithAttributes: catch outer")
+            NotificationsUtilities.clearNotification(mContext, callObject.mNotificationId)
         }
     }
 
     private suspend fun addCall(attributes: CallAttributesCompat, callObject: VoipCall) {
-        mCallsManager!!.addCall(
+        mCallsManager.addCall(
             attributes,
             callObject.mOnAnswerLambda,
             callObject.mOnDisconnectLambda,
             callObject.mOnSetActiveLambda,
             callObject.mOnSetInActiveLambda,
         ) {
+            postNotification(attributes, callObject)
             mPreCallEndpointAdapter.mSelectedCallEndpoint = null
             // inject client control interface into the VoIP call object
             callObject.setCallId(getCallId().toString())
             callObject.setCallControl(this)
 
+            launch {
+                mNotificationActionInfoFlow.collect {
+                    if (it.id == callObject.mNotificationId) {
+                        if (it.isAnswer) {
+                            answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)
+                        } else {
+                            disconnect(DisconnectCause(DisconnectCause.LOCAL))
+                        }
+                        handleUpdateToNotification(it, attributes, callObject)
+                    }
+                }
+            }
             // Collect updates
             launch { currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) } }
 
@@ -241,6 +295,23 @@
         }
     }
 
+    private fun handleUpdateToNotification(
+        it: NotificationActionInfo,
+        attributes: CallAttributesCompat,
+        callObject: VoipCall
+    ) {
+        if (it.isAnswer) {
+            NotificationsUtilities.updateNotificationToOngoing(
+                mContext,
+                callObject.mNotificationId,
+                NOTIFICATION_CHANNEL_ID,
+                attributes.displayName.toString()
+            )
+        } else {
+            NotificationsUtilities.clearNotification(mContext, callObject.mNotificationId)
+        }
+    }
+
     @OptIn(ExperimentalAppActions::class)
     private suspend fun addCallWithExtensions(
         attributes: CallAttributesCompat,
@@ -248,7 +319,7 @@
         isRaiseHandEnabled: Boolean = false,
         isKickParticipantEnabled: Boolean = false
     ) {
-        mCallsManager!!.addCallWithExtensions(
+        mCallsManager.addCallWithExtensions(
             attributes,
             callObject.mOnAnswerLambda,
             callObject.mOnDisconnectLambda,
@@ -282,6 +353,7 @@
                 }
             }
             onCall {
+                postNotification(attributes, callObject)
                 mPreCallEndpointAdapter.mSelectedCallEndpoint = null
                 // inject client control interface into the VoIP call object
                 callObject.setCallId(getCallId().toString())
@@ -293,7 +365,18 @@
                     )
                 )
                 addCallRow(callObject)
-
+                launch {
+                    mNotificationActionInfoFlow.collect {
+                        if (it.id == callObject.mNotificationId) {
+                            if (it.isAnswer) {
+                                answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)
+                            } else {
+                                disconnect(DisconnectCause(DisconnectCause.LOCAL))
+                            }
+                            handleUpdateToNotification(it, attributes, callObject)
+                        }
+                    }
+                }
                 // Collect updates
                 participants.participants
                     .onEach {
@@ -329,7 +412,7 @@
     }
 
     private fun fetchPreCallEndpoints(cancelFlowButton: Button) {
-        val endpointsFlow = mCallsManager!!.getAvailableStartingCallEndpoints()
+        val endpointsFlow = mCallsManager.getAvailableStartingCallEndpoints()
         CoroutineScope(Dispatchers.Default).launch {
             launch {
                 val endpointsCoroutineScope = this
@@ -351,12 +434,24 @@
         }
     }
 
+    private fun postNotification(attributes: CallAttributesCompat, voipCall: VoipCall) {
+        val notification =
+            NotificationsUtilities.createInitialCallStyleNotification(
+                mContext,
+                voipCall.mNotificationId,
+                NOTIFICATION_CHANNEL_ID,
+                attributes.displayName.toString(),
+                attributes.direction == DIRECTION_OUTGOING
+            )
+        mNotificationManager.notify(voipCall.mNotificationId, notification)
+    }
+
     private fun logException(e: Exception, prefix: String) {
         Log.i(TAG, "$prefix: e=[$e], e.msg=[${e.message}], e.stack:${e.printStackTrace()}")
     }
 
     private fun addCallRow(callObject: VoipCall) {
-        mCallObjects.add(CallRow(++mCallCount, callObject))
+        mCallObjects.add(CallRow(++mCurrentCallCount, callObject))
         callObject.setCallAdapter(mAdapter)
         updateCallList()
     }
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Constants.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Constants.kt
new file mode 100644
index 0000000..3086853
--- /dev/null
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Constants.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 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 androidx.core.telecom.test
+
+import android.net.Uri
+import androidx.annotation.RequiresApi
+import androidx.core.telecom.CallAttributesCompat
+
+@RequiresApi(34)
+class Constants {
+    companion object {
+        const val APP_SCHEME = "MyCustomScheme:"
+        const val TEST_NUMBER = "6506958985"
+        const val ALL_CALL_CAPABILITIES =
+            (CallAttributesCompat.SUPPORTS_SET_INACTIVE or
+                CallAttributesCompat.SUPPORTS_STREAM or
+                CallAttributesCompat.SUPPORTS_TRANSFER)
+
+        // outgoing attributes constants
+        const val OUTGOING_NAME = "Darth Maul"
+        val OUTGOING_URI: Uri = Uri.parse(APP_SCHEME + TEST_NUMBER)
+
+        // incoming attributes constants
+        const val INCOMING_NAME = "Sundar Pichai"
+        val INCOMING_URI: Uri = Uri.parse(APP_SCHEME + TEST_NUMBER)
+    }
+}
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/NotificationsUtilities.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/NotificationsUtilities.kt
new file mode 100644
index 0000000..1d9509d
--- /dev/null
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/NotificationsUtilities.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024 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 androidx.core.telecom.test
+
+import android.R
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Person
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.telecom.util.ExperimentalAppActions
+
+@ExperimentalAppActions
+@RequiresApi(Build.VERSION_CODES.S)
+class NotificationsUtilities {
+    companion object {
+        val TAG: String = NotificationsUtilities::class.java.getSimpleName()
+        val NOTIFICATION_CHANNEL_ID = "CoreTelecomTestVoipApp"
+        val CHANNEL_NAME = "Core Telecom Test Voip App Channel"
+        val CHANNEL_DESCRIPTION =
+            "Core Telecom Test Voip calls will post call-style" + " notifications to this channel"
+        val CALL_STYLE_INITIAL_TEXT: String = "New Call"
+        val CALL_STYLE_ONGOING_TEXT: String = "Ongoing Call"
+        val CALL_STYLE_TITLE: String = "Core-Telecom Test VoIP App"
+        val NOTIFICATION_ACTION_ANSWER = "androidx.core.telecom.test.ANSWER"
+        val NOTIFICATION_ACTION_DECLINE = "androidx.core.telecom.test.DECLINE"
+        val NOTIFICATION_ID = "androidx.core.telecom.test.ID"
+        val IS_ANSWER_ACTION = "androidx.core.telecom.test.IS_ANSWER_ACTION"
+
+        fun createInitialCallStyleNotification(
+            context: Context,
+            uniqueId: Int,
+            channelId: String?,
+            callerName: String?,
+            isOutgoing: Boolean
+        ): Notification {
+            val fullScreenIntent = getDeclinePendingIntent(context, uniqueId)
+            val person: Person = Person.Builder().setName(callerName).setImportant(true).build()
+            return Notification.Builder(context, channelId)
+                .setContentText(CALL_STYLE_INITIAL_TEXT)
+                .setContentTitle(CALL_STYLE_TITLE)
+                .setSmallIcon(R.drawable.sym_def_app_icon)
+                .setStyle(getCallStyle(context, isOutgoing, person, fullScreenIntent, uniqueId))
+                .setFullScreenIntent(fullScreenIntent, true)
+                .setOngoing(isOutgoing)
+                .build()
+        }
+
+        private fun getCallStyle(
+            c: Context,
+            isOutgoing: Boolean,
+            person: Person,
+            fullScreenIntent: PendingIntent,
+            notificationId: Int,
+        ): Notification.CallStyle {
+            return if (isOutgoing) {
+                Notification.CallStyle.forOngoingCall(person, fullScreenIntent)
+            } else {
+                Notification.CallStyle.forIncomingCall(
+                    person,
+                    getDeclinePendingIntent(c, notificationId),
+                    getAnswerPendingIntent(c, notificationId)
+                )
+            }
+        }
+
+        fun updateNotificationToOngoing(
+            context: Context,
+            notificationId: Int,
+            channelId: String?,
+            callerName: String?,
+        ) {
+            val endCallAction = getDeclinePendingIntent(context, notificationId)
+            val callStyleNotification =
+                Notification.Builder(context, channelId)
+                    .setContentText(CALL_STYLE_ONGOING_TEXT)
+                    .setContentTitle(CALL_STYLE_TITLE)
+                    .setSmallIcon(R.drawable.sym_def_app_icon)
+                    .setStyle(
+                        Notification.CallStyle.forOngoingCall(
+                            Person.Builder().setName(callerName).setImportant(true).build(),
+                            endCallAction
+                        )
+                    )
+                    .setFullScreenIntent(endCallAction, true)
+                    .setOngoing(true)
+                    .build()
+
+            val notificationManager = context.getSystemService(NotificationManager::class.java)
+            notificationManager.notify(notificationId, callStyleNotification)
+        }
+
+        private fun getDeclinePendingIntent(context: Context, notificationId: Int): PendingIntent {
+            return PendingIntent.getActivity(
+                context,
+                notificationId,
+                getDeclineIntent(context, notificationId),
+                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+            )
+        }
+
+        private fun getDeclineIntent(c: Context, notificationId: Int): Intent {
+            val declineIntent =
+                Intent(c, CallingMainActivity::class.java).apply {
+                    action = NOTIFICATION_ACTION_DECLINE
+                    putExtra(NOTIFICATION_ID, notificationId)
+                    putExtra(IS_ANSWER_ACTION, false)
+                }
+            declineIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+            return declineIntent
+        }
+
+        private fun getAnswerPendingIntent(c: Context, notificationId: Int): PendingIntent {
+            return PendingIntent.getActivity(
+                c,
+                notificationId,
+                getAnswerIntent(c, notificationId),
+                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+            )
+        }
+
+        private fun getAnswerIntent(c: Context, notificationId: Int): Intent {
+            val answerIntent =
+                Intent(c, CallingMainActivity::class.java).apply {
+                    action = NOTIFICATION_ACTION_ANSWER
+                    putExtra(NOTIFICATION_ID, notificationId)
+                    putExtra(IS_ANSWER_ACTION, true)
+                }
+            answerIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+            return answerIntent
+        }
+
+        fun clearNotification(c: Context, notificationId: Int) {
+            val notificationManager = c.getSystemService(NotificationManager::class.java)
+            notificationManager?.cancel(notificationId)
+        }
+
+        fun initNotificationChannel(c: Context) {
+            val importance = NotificationManager.IMPORTANCE_DEFAULT
+            val channel =
+                NotificationChannel(NOTIFICATION_CHANNEL_ID, CHANNEL_NAME, importance).apply {
+                    description = CHANNEL_DESCRIPTION
+                }
+            val nm = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+            nm.createNotificationChannel(channel)
+        }
+
+        fun deleteNotificationChannel(c: Context) {
+            val notificationManager = c.getSystemService(NotificationManager::class.java)
+            try {
+                notificationManager.deleteNotificationChannel(NOTIFICATION_CHANNEL_ID)
+            } catch (e: Exception) {
+                Log.i(
+                    TAG,
+                    String.format(
+                        "notificationManager: hit exception=[%s] while deleting the" +
+                            " call channel with id=[%s]",
+                        e,
+                        NOTIFICATION_CHANNEL_ID
+                    )
+                )
+            }
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt
deleted file mode 100644
index cb6123b..0000000
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2023 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 androidx.core.telecom.test
-
-import android.net.Uri
-import androidx.annotation.RequiresApi
-import androidx.core.telecom.CallAttributesCompat
-
-@RequiresApi(34)
-class Utilities {
-    companion object {
-        const val APP_SCHEME = "MyCustomScheme:"
-        const val TEST_NUMBER = "6506958985"
-        const val ALL_CALL_CAPABILITIES =
-            (CallAttributesCompat.SUPPORTS_SET_INACTIVE or
-                CallAttributesCompat.SUPPORTS_STREAM or
-                CallAttributesCompat.SUPPORTS_TRANSFER)
-
-        // outgoing attributes constants
-        const val OUTGOING_NAME = "Darth Maul"
-        val OUTGOING_URI: Uri = Uri.parse(APP_SCHEME + TEST_NUMBER)
-
-        // incoming attributes constants
-        const val INCOMING_NAME = "Sundar Pichai"
-        val INCOMING_URI: Uri = Uri.parse(APP_SCHEME + TEST_NUMBER)
-    }
-}
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
index a57edc5..25bb650 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
@@ -16,12 +16,15 @@
 
 package androidx.core.telecom.test
 
+import android.content.Context
 import android.telecom.DisconnectCause
 import android.util.Log
 import androidx.annotation.RequiresApi
+import androidx.core.telecom.CallAttributesCompat
 import androidx.core.telecom.CallControlScope
 import androidx.core.telecom.CallEndpointCompat
 import androidx.core.telecom.extensions.LocalCallSilenceExtension
+import androidx.core.telecom.test.NotificationsUtilities.Companion.NOTIFICATION_CHANNEL_ID
 import androidx.core.telecom.util.ExperimentalAppActions
 import kotlin.coroutines.coroutineContext
 import kotlinx.coroutines.CoroutineScope
@@ -29,9 +32,9 @@
 
 @ExperimentalAppActions
 @RequiresApi(34)
-class VoipCall {
+class VoipCall(val context: Context, val attributes: CallAttributesCompat) {
     private val TAG = VoipCall::class.simpleName
-
+    var mNotificationId: Int = -1
     var mAdapter: CallListAdapter? = null
     var mCallControl: CallControlScope? = null
     var mParticipantControl: ParticipantControl? = null
@@ -40,6 +43,7 @@
     var mIsMuted = false
     var mTelecomCallId: String = ""
     var mIsLocallySilence: Boolean = false
+    var hasUpdatedToOngoing = false
     var mLocalCallSilenceExtension: LocalCallSilenceExtension? = null
 
     val mOnSetActiveLambda: suspend () -> Unit = {
@@ -55,10 +59,12 @@
     val mOnAnswerLambda: suspend (type: Int) -> Unit = {
         Log.i(TAG, "onAnswer: callType=[$it]")
         mAdapter?.updateCallState(mTelecomCallId, "Answered")
+        updateNotificationToOngoing()
     }
 
     val mOnDisconnectLambda: suspend (cause: DisconnectCause) -> Unit = {
         Log.i(TAG, "onDisconnect: disconnectCause=[$it]")
+        NotificationsUtilities.clearNotification(context, mNotificationId)
         mAdapter?.updateCallState(mTelecomCallId, "Disconnected")
     }
 
@@ -70,6 +76,27 @@
         mParticipantControl = participantControl
     }
 
+    fun setNotificationId(id: Int) {
+        mNotificationId = id
+    }
+
+    fun clearNotification() {
+        Log.i(TAG, "clearNotification with id=[$mNotificationId]")
+        NotificationsUtilities.clearNotification(context, mNotificationId)
+    }
+
+    fun updateNotificationToOngoing() {
+        if (!hasUpdatedToOngoing) {
+            NotificationsUtilities.updateNotificationToOngoing(
+                context = context,
+                notificationId = mNotificationId,
+                channelId = NOTIFICATION_CHANNEL_ID,
+                callerName = attributes.displayName.toString()
+            )
+        }
+        hasUpdatedToOngoing = true
+    }
+
     @OptIn(ExperimentalAppActions::class)
     suspend fun toggleLocalCallSilence() {
         CoroutineScope(coroutineContext).launch {
diff --git a/core/core-telecom/integration-tests/testicsapp/build.gradle b/core/core-telecom/integration-tests/testicsapp/build.gradle
index c3ce464..27427c3 100644
--- a/core/core-telecom/integration-tests/testicsapp/build.gradle
+++ b/core/core-telecom/integration-tests/testicsapp/build.gradle
@@ -46,7 +46,7 @@
     //@Serialize
     implementation(libs.kotlinSerializationCore)
     // Test package
-    implementation project(":core:core-telecom")
+    implementation(project(":core:core-telecom"))
     // Compose
     implementation("androidx.activity:activity-compose:1.9.1")
     // Themes and Dynamic coloring
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt b/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
index afcd9bc..94ecba9 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
@@ -418,7 +418,6 @@
      * @param onEvent Incoming {@link CallEvents} from an InCallService implementation
      * @see addCall For more documentation on the operations/parameters of this class
      */
-    @Suppress("ClassVerificationFailure")
     @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)
     internal suspend fun addCall(
         callAttributes: CallAttributesCompat,
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
index 14c8903..a96b470 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
@@ -49,7 +49,6 @@
 import kotlinx.coroutines.withTimeout
 
 @RequiresApi(34)
-@Suppress("ClassVerificationFailure")
 internal class CallSession(
     val coroutineContext: CoroutineContext,
     val attributes: CallAttributesCompat,
diff --git a/core/core/build.gradle b/core/core/build.gradle
index 38a9b3f..4737ed5 100644
--- a/core/core/build.gradle
+++ b/core/core/build.gradle
@@ -15,6 +15,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     // Atomically versioned.
     constraints {
         implementation(project(":core:core-ktx"))
@@ -108,6 +109,4 @@
             "features."
     failOnDeprecationWarnings = false
     samples(project(":core:core:core-samples"))
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
diff --git a/core/core/src/androidTest/java/android/support/v4/testutils/TestUtils.java b/core/core/src/androidTest/java/android/support/v4/testutils/TestUtils.java
index 1b3a83a..53a27de 100644
--- a/core/core/src/androidTest/java/android/support/v4/testutils/TestUtils.java
+++ b/core/core/src/androidTest/java/android/support/v4/testutils/TestUtils.java
@@ -27,7 +27,8 @@
 import android.graphics.drawable.Drawable;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
+
+import org.jspecify.annotations.NonNull;
 
 public class TestUtils {
     /**
diff --git a/core/core/src/androidTest/java/android/support/v4/testutils/TextViewActions.java b/core/core/src/androidTest/java/android/support/v4/testutils/TextViewActions.java
index e425594..a38c9e4c 100644
--- a/core/core/src/androidTest/java/android/support/v4/testutils/TextViewActions.java
+++ b/core/core/src/androidTest/java/android/support/v4/testutils/TextViewActions.java
@@ -23,7 +23,6 @@
 import android.widget.TextView;
 
 import androidx.annotation.DrawableRes;
-import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.annotation.StyleRes;
 import androidx.core.widget.TextViewCompat;
@@ -31,6 +30,7 @@
 import androidx.test.espresso.ViewAction;
 
 import org.hamcrest.Matcher;
+import org.jspecify.annotations.Nullable;
 
 public class TextViewActions {
     /**
diff --git a/core/core/src/androidTest/java/androidx/core/app/GrammaticalInfectionActivity.java b/core/core/src/androidTest/java/androidx/core/app/GrammaticalInfectionActivity.java
index 07e58d1..82204ca 100644
--- a/core/core/src/androidTest/java/androidx/core/app/GrammaticalInfectionActivity.java
+++ b/core/core/src/androidTest/java/androidx/core/app/GrammaticalInfectionActivity.java
@@ -21,7 +21,7 @@
 import android.os.Bundle;
 import android.view.WindowManager;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
diff --git a/core/core/src/androidTest/java/androidx/core/app/JobIntentServiceTest.java b/core/core/src/androidTest/java/androidx/core/app/JobIntentServiceTest.java
index 007491b..5523b58 100644
--- a/core/core/src/androidTest/java/androidx/core/app/JobIntentServiceTest.java
+++ b/core/core/src/androidTest/java/androidx/core/app/JobIntentServiceTest.java
@@ -30,12 +30,12 @@
 import android.os.Parcelable;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Test;
diff --git a/core/core/src/androidTest/java/androidx/core/app/NotificationChannelCompatTest.java b/core/core/src/androidTest/java/androidx/core/app/NotificationChannelCompatTest.java
index e4087d5..7cedf48 100644
--- a/core/core/src/androidTest/java/androidx/core/app/NotificationChannelCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/app/NotificationChannelCompatTest.java
@@ -40,7 +40,6 @@
 import java.util.Collection;
 import java.util.Objects;
 
-
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class NotificationChannelCompatTest {
diff --git a/core/core/src/androidTest/java/androidx/core/app/NotificationChannelGroupCompatTest.java b/core/core/src/androidTest/java/androidx/core/app/NotificationChannelGroupCompatTest.java
index 95ad4a2..ff98d88 100644
--- a/core/core/src/androidTest/java/androidx/core/app/NotificationChannelGroupCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/app/NotificationChannelGroupCompatTest.java
@@ -22,12 +22,12 @@
 import android.app.NotificationChannelGroup;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.core.app.NotificationChannelGroupCompat.Builder;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -35,7 +35,6 @@
 
 import javax.annotation.Nullable;
 
-
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class NotificationChannelGroupCompatTest {
diff --git a/core/core/src/androidTest/java/androidx/core/app/NotificationManagerCompatTest.java b/core/core/src/androidTest/java/androidx/core/app/NotificationManagerCompatTest.java
index a264df7..e3a99a8 100644
--- a/core/core/src/androidTest/java/androidx/core/app/NotificationManagerCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/app/NotificationManagerCompatTest.java
@@ -26,14 +26,12 @@
 import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_MAX;
 import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_MIN;
 
-import static org.mockito.Mockito.spy;
-
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
@@ -64,7 +62,6 @@
 import java.util.List;
 import java.util.UUID;
 
-
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class NotificationManagerCompatTest {
diff --git a/core/core/src/androidTest/java/androidx/core/app/RemoteActionCompatTest.java b/core/core/src/androidTest/java/androidx/core/app/RemoteActionCompatTest.java
index 488dcdf..a9e6a506 100644
--- a/core/core/src/androidTest/java/androidx/core/app/RemoteActionCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/app/RemoteActionCompatTest.java
@@ -33,7 +33,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class RemoteActionCompatTest extends BaseInstrumentationTestCase<TestActivity> {
diff --git a/core/core/src/androidTest/java/androidx/core/content/res/ResourcesCompatTest.java b/core/core/src/androidTest/java/androidx/core/content/res/ResourcesCompatTest.java
index e877a26..0a67b74 100644
--- a/core/core/src/androidTest/java/androidx/core/content/res/ResourcesCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/content/res/ResourcesCompatTest.java
@@ -36,7 +36,6 @@
 import android.support.v4.testutils.TestUtils;
 import android.util.DisplayMetrics;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.TypefaceCompat;
 import androidx.core.provider.FontsContractCompat;
 import androidx.core.provider.MockFontProvider;
@@ -47,6 +46,7 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Test;
 
diff --git a/core/core/src/androidTest/java/androidx/core/graphics/PaintTest.java b/core/core/src/androidTest/java/androidx/core/graphics/PaintTest.java
index acbb9cf..3c1d464 100644
--- a/core/core/src/androidTest/java/androidx/core/graphics/PaintTest.java
+++ b/core/core/src/androidTest/java/androidx/core/graphics/PaintTest.java
@@ -27,11 +27,11 @@
 import android.graphics.PorterDuffXfermode;
 import android.graphics.Xfermode;
 
-import androidx.annotation.Nullable;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -93,7 +93,7 @@
      * equivalent PorterDuff.Mode for the BlendMode
      */
     private void verifyPorterDuffMatchesCompat(@Nullable BlendModeCompat compat,
-                                               @Nullable PorterDuff.Mode mode) {
+                                               PorterDuff.@Nullable Mode mode) {
         Paint p = new Paint();
         boolean result = PaintCompat.setBlendMode(p, compat);
         if (compat != null && mode == null) {
diff --git a/core/core/src/androidTest/java/androidx/core/graphics/PaintTestApi29.java b/core/core/src/androidTest/java/androidx/core/graphics/PaintTestApi29.java
index df91c96e..1565f4b 100644
--- a/core/core/src/androidTest/java/androidx/core/graphics/PaintTestApi29.java
+++ b/core/core/src/androidTest/java/androidx/core/graphics/PaintTestApi29.java
@@ -23,11 +23,11 @@
 import android.graphics.BlendMode;
 import android.graphics.Paint;
 
-import androidx.annotation.NonNull;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/core/core/src/androidTest/java/androidx/core/graphics/TypefaceCompatTest.java b/core/core/src/androidTest/java/androidx/core/graphics/TypefaceCompatTest.java
index eb5eb6b..c5e054f 100644
--- a/core/core/src/androidTest/java/androidx/core/graphics/TypefaceCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/graphics/TypefaceCompatTest.java
@@ -34,7 +34,6 @@
 import android.graphics.Typeface;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.appcompat.view.ContextThemeWrapper;
 import androidx.appcompat.widget.AppCompatTextView;
 import androidx.core.content.res.FontResourcesParserCompat;
@@ -51,6 +50,7 @@
 
 import com.google.common.truth.Truth;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/core/core/src/androidTest/java/androidx/core/os/TraceCompatTest.java b/core/core/src/androidTest/java/androidx/core/os/TraceCompatTest.java
index c62c8b5..528be61 100644
--- a/core/core/src/androidTest/java/androidx/core/os/TraceCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/os/TraceCompatTest.java
@@ -26,14 +26,14 @@
 import android.os.Build;
 import android.os.ParcelFileDescriptor;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
diff --git a/core/core/src/androidTest/java/androidx/core/provider/FontsContractCompatTest.java b/core/core/src/androidTest/java/androidx/core/provider/FontsContractCompatTest.java
index 3b65ec1..57b810f 100644
--- a/core/core/src/androidTest/java/androidx/core/provider/FontsContractCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/provider/FontsContractCompatTest.java
@@ -44,13 +44,13 @@
 import android.os.Looper;
 import android.util.Base64;
 
-import androidx.annotation.NonNull;
 import androidx.core.provider.FontsContractCompat.FontFamilyResult;
 import androidx.core.provider.FontsContractCompat.FontInfo;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/core/core/src/androidTest/java/androidx/core/text/method/LinkMovementMethodCompatTest.java b/core/core/src/androidTest/java/androidx/core/text/method/LinkMovementMethodCompatTest.java
index 7eb6b39..131e129 100644
--- a/core/core/src/androidTest/java/androidx/core/text/method/LinkMovementMethodCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/text/method/LinkMovementMethodCompatTest.java
@@ -38,12 +38,12 @@
 import android.view.inputmethod.InputConnection;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.core.app.TestActivity;
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
diff --git a/core/core/src/androidTest/java/androidx/core/util/AtomicFileTest.java b/core/core/src/androidTest/java/androidx/core/util/AtomicFileTest.java
index 534b4c5..5b01337 100644
--- a/core/core/src/androidTest/java/androidx/core/util/AtomicFileTest.java
+++ b/core/core/src/androidTest/java/androidx/core/util/AtomicFileTest.java
@@ -22,11 +22,11 @@
 import android.app.Instrumentation;
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.filters.MediumTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -66,12 +66,9 @@
     private static final byte[] NEW_BYTES = "new".getBytes(UTF_8);
     private static final byte[] LEGACY_BACKUP_BYTES = "bak".getBytes(UTF_8);
 
-    @Nullable
-    public String[] mExistingFileNames;
-    @Nullable
-    public WriteAction mWriteAction;
-    @Nullable
-    public byte[] mExpectedBytes;
+    public String @Nullable [] mExistingFileNames;
+    public @Nullable WriteAction mWriteAction;
+    public byte @Nullable [] mExpectedBytes;
 
     private final Instrumentation mInstrumentation =
             InstrumentationRegistry.getInstrumentation();
@@ -273,7 +270,7 @@
         }
     }
 
-    private static void writeBytes(@NonNull File file, @NonNull byte[] bytes) throws IOException {
+    private static void writeBytes(@NonNull File file, byte @NonNull [] bytes) throws IOException {
         try (FileOutputStream outputStream = new FileOutputStream(file)) {
             outputStream.write(bytes);
         }
@@ -319,15 +316,12 @@
     // JUnit on API 17 somehow turns null parameters into the string "null". Wrapping the parameters
     // inside a class solves this problem.
     private static class Parameters {
-        @Nullable
-        public String[] existingFileNames;
-        @Nullable
-        public WriteAction writeAction;
-        @Nullable
-        public byte[] expectedBytes;
+        public String @Nullable [] existingFileNames;
+        public @Nullable WriteAction writeAction;
+        public byte @Nullable [] expectedBytes;
 
-        Parameters(@Nullable String[] existingFileNames, @Nullable WriteAction writeAction,
-                @Nullable byte[] expectedBytes) {
+        Parameters(String @Nullable [] existingFileNames, @Nullable WriteAction writeAction,
+                byte @Nullable [] expectedBytes) {
             this.existingFileNames = existingFileNames;
             this.writeAction = writeAction;
             this.expectedBytes = expectedBytes;
diff --git a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
index 7dd5bc8..6d2d5f9 100644
--- a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
@@ -48,8 +48,6 @@
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeProvider;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
 import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
@@ -63,6 +61,8 @@
 
 import com.google.common.truth.Truth;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/core/core/src/androidTest/java/androidx/core/view/DragStartHelperTest.java b/core/core/src/androidTest/java/androidx/core/view/DragStartHelperTest.java
index 0dfb788..10208b6 100644
--- a/core/core/src/androidTest/java/androidx/core/view/DragStartHelperTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/DragStartHelperTest.java
@@ -34,7 +34,6 @@
 import android.view.View;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.NonNull;
 import androidx.core.test.R;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
@@ -42,6 +41,7 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Rule;
@@ -64,16 +64,14 @@
         boolean onDragStart(View view, DragStartHelper helper, Point touchPosition);
     }
 
-    @NonNull
-    private DragStartListener createListener(boolean returnValue) {
+    private @NonNull DragStartListener createListener(boolean returnValue) {
         final DragStartListener listener = mock(DragStartListener.class);
         when(listener.onDragStart(any(View.class), any(DragStartHelper.class), any(Point.class)))
                 .thenReturn(returnValue);
         return listener;
     }
 
-    @NonNull
-    private DragStartHelper createDragStartHelper(final DragStartListener listener) {
+    private @NonNull DragStartHelper createDragStartHelper(final DragStartListener listener) {
         return new DragStartHelper(mDragSource, new DragStartHelper.OnDragStartListener() {
             @Override
             public boolean onDragStart(@NonNull View v, @NonNull DragStartHelper helper) {
diff --git a/core/core/src/androidTest/java/androidx/core/view/NestedScrollingChildHelperTest.java b/core/core/src/androidTest/java/androidx/core/view/NestedScrollingChildHelperTest.java
index 849ee9c..c2bd340 100644
--- a/core/core/src/androidTest/java/androidx/core/view/NestedScrollingChildHelperTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/NestedScrollingChildHelperTest.java
@@ -28,13 +28,13 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.widget.NestedScrollView;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -190,14 +190,14 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @Nullable [] consumed) {
         }
     }
 
diff --git a/core/core/src/androidTest/java/androidx/core/view/NestedScrollingHelperIntegrationTest.java b/core/core/src/androidTest/java/androidx/core/view/NestedScrollingHelperIntegrationTest.java
index ba0f2ba..05ff986 100644
--- a/core/core/src/androidTest/java/androidx/core/view/NestedScrollingHelperIntegrationTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/NestedScrollingHelperIntegrationTest.java
@@ -23,12 +23,12 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -151,7 +151,7 @@
 
         @Override
         public void onNestedPreScroll(@NonNull View target, int dx, int dy,
-                 @NonNull int[] consumed) {
+                 int @NonNull [] consumed) {
             dispatchNestedPreScroll(dx, dy, consumed, null);
         }
 
@@ -198,14 +198,14 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow) {
             return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                     dxUnconsumed, dyUnconsumed, offsetInWindow);
         }
 
         @Override
-        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-                @Nullable int[] offsetInWindow) {
+        public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+                int @Nullable [] offsetInWindow) {
             return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed,
                     offsetInWindow);
         }
@@ -253,8 +253,8 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
             dispatchNestedPreScroll(dx, dy, consumed, null);
         }
 
@@ -275,14 +275,14 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
             return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                     dxUnconsumed, dyUnconsumed, offsetInWindow, type);
         }
 
         @Override
-        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-                @Nullable int[] offsetInWindow, int type) {
+        public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+                int @Nullable [] offsetInWindow, int type) {
             return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed,
                     offsetInWindow, type);
         }
@@ -297,15 +297,15 @@
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @NonNull [] consumed) {
             dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed,
                     dyUnconsumed, null, type, consumed);
         }
 
         @Override
         public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
-                @NonNull int[] consumed) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type,
+                int @NonNull [] consumed) {
             mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                     dxUnconsumed, dyUnconsumed, offsetInWindow, type, consumed);
         }
diff --git a/core/core/src/androidTest/java/androidx/core/view/PointerIconCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/PointerIconCompatTest.java
index c3df6d4..fd0202e 100644
--- a/core/core/src/androidTest/java/androidx/core/view/PointerIconCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/PointerIconCompatTest.java
@@ -36,7 +36,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewCompatReceiveContentTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewCompatReceiveContentTest.java
index b3d7604..1b05c2c 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewCompatReceiveContentTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewCompatReceiveContentTest.java
@@ -24,14 +24,14 @@
 import android.os.Build;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.R;
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -107,8 +107,7 @@
         } catch (IllegalArgumentException expected) { }
     }
 
-    @Nullable
-    private static OnReceiveContentListener getListener(@NonNull View view) {
+    private static @Nullable OnReceiveContentListener getListener(@NonNull View view) {
         return (OnReceiveContentListener) view.getTag(R.id.tag_on_receive_content_listener);
     }
 }
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewCompatTest.java
index 8b6fd8e..f3a29d8 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewCompatTest.java
@@ -68,8 +68,6 @@
 import android.view.contentcapture.ContentCaptureSession;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.graphics.Insets;
 import androidx.core.test.R;
 import androidx.core.view.autofill.AutofillIdCompat;
@@ -80,6 +78,8 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.SdkSuppress;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -621,7 +621,7 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow) {
             return true;
         }
     }
@@ -635,7 +635,7 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
             return true;
         }
     }
@@ -649,8 +649,8 @@
 
         @Override
         public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
-                @NonNull int[] consumed) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type,
+                int @NonNull [] consumed) {
         }
     }
 }
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewConfigurationCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewConfigurationCompatTest.java
index d194ec9..f0387d6 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewConfigurationCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewConfigurationCompatTest.java
@@ -33,12 +33,12 @@
 import android.view.InputDevice;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -330,8 +330,7 @@
                         mContext, vc, inputDeviceId, axis, source));
     }
 
-    @Nullable
-    private InputDevice findInputDevice(int source) {
+    private @Nullable InputDevice findInputDevice(int source) {
         InputManager inputManager =
                 (InputManager) mContext.getSystemService(Context.INPUT_SERVICE);
         int[] deviceIds = inputManager.getInputDeviceIds();
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
index 4009846..22fdbd4 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
@@ -34,20 +34,19 @@
 import android.view.WindowInsets;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.Insets;
 import androidx.core.test.R;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.util.List;
 
-
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public class ViewGroupCompatTest extends BaseInstrumentationTestCase<ViewCompatActivity> {
@@ -136,9 +135,9 @@
             // View.OnApplyWindowInsetsListener set by ViewGroupCompat#installCompatInsetsDispatch
             ViewCompat.setWindowInsetsAnimationCallback(mViewGroup,
                     new WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
-                        @NonNull
                         @Override
-                        public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets,
+                        public @NonNull WindowInsetsCompat onProgress(
+                                @NonNull WindowInsetsCompat insets,
                                 @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
                             return insets;
                         }
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewParentCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewParentCompatTest.java
index 0f0a738..f93fcb5 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewParentCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewParentCompatTest.java
@@ -24,18 +24,17 @@
 import android.view.View;
 import android.view.ViewParent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.filters.SdkSuppress;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public class ViewParentCompatTest {
@@ -189,7 +188,7 @@
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @Nullable [] consumed) {
         }
     }
 }
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/EditorInfoCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/EditorInfoCompatTest.java
index 70bf03c..7a93e2d 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/EditorInfoCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/EditorInfoCompatTest.java
@@ -33,16 +33,15 @@
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
 
-import androidx.annotation.Nullable;
 import androidx.core.app.TestActivity;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.filters.SdkSuppress;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public class EditorInfoCompatTest extends BaseInstrumentationTestCase<TestActivity> {
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeBaseSplitTestActivity.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeBaseSplitTestActivity.java
index 279d763..f4ae401 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeBaseSplitTestActivity.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeBaseSplitTestActivity.java
@@ -19,10 +19,11 @@
 import android.app.Activity;
 import android.os.Bundle;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.R;
 
+import org.jspecify.annotations.Nullable;
+
 @RequiresApi(30)
 public class ImeBaseSplitTestActivity extends Activity {
 
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitTestActivity.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitTestActivity.java
index 1820e0e..90cd5b9 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitTestActivity.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitTestActivity.java
@@ -21,13 +21,14 @@
 import android.widget.Button;
 import android.widget.EditText;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.R;
 import androidx.core.view.WindowCompat;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.core.view.WindowInsetsControllerCompat;
 
+import org.jspecify.annotations.Nullable;
+
 @RequiresApi(30)
 public class ImeSecondarySplitTestActivity extends Activity {
 
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitViewCompatTestActivity.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitViewCompatTestActivity.java
index 5bcd1e286..9349ef6 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitViewCompatTestActivity.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeSecondarySplitViewCompatTestActivity.java
@@ -21,14 +21,14 @@
 import android.widget.Button;
 import android.widget.EditText;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.R;
 import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowCompat;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.core.view.WindowInsetsControllerCompat;
 
+import org.jspecify.annotations.Nullable;
+
 @RequiresApi(30)
 public class ImeSecondarySplitViewCompatTestActivity extends Activity {
 
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/InputConnectionCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/InputConnectionCompatTest.java
index db51dd9..fe64bce 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/InputConnectionCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/InputConnectionCompatTest.java
@@ -46,7 +46,6 @@
 
 import java.util.Objects;
 
-
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public class InputConnectionCompatTest {
diff --git a/core/core/src/androidTest/java/androidx/core/widget/EdgeEffectCompatTest.java b/core/core/src/androidTest/java/androidx/core/widget/EdgeEffectCompatTest.java
index 80537e2..d7c743c 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/EdgeEffectCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/EdgeEffectCompatTest.java
@@ -27,13 +27,13 @@
 import android.view.View;
 import android.widget.EdgeEffect;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.test.R;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/core/core/src/androidTest/java/androidx/core/widget/ListViewCompatTest.java b/core/core/src/androidTest/java/androidx/core/widget/ListViewCompatTest.java
index 0d4afc2..ad117be 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/ListViewCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/ListViewCompatTest.java
@@ -30,12 +30,12 @@
 import android.widget.ListView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.test.R;
 import androidx.test.filters.MediumTest;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -89,8 +89,8 @@
         }, false);
     }
 
-    private void runOnMainAndLayoutSync(@NonNull final ActivityTestRule activityTestRule,
-            @NonNull final View view, @Nullable final Runnable runner, final boolean forceLayout)
+    private void runOnMainAndLayoutSync(final @NonNull ActivityTestRule activityTestRule,
+            final @NonNull View view, final @Nullable Runnable runner, final boolean forceLayout)
             throws Throwable {
         final View rootView = view.getRootView();
 
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingA11yScrollTest.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingA11yScrollTest.java
index 27ca404..5eb26a2 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingA11yScrollTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingA11yScrollTest.java
@@ -35,8 +35,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.test.R;
 import androidx.core.view.NestedScrollingParent3;
 import androidx.core.view.ViewCompat;
@@ -46,6 +44,8 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 
 import org.jetbrains.annotations.NotNull;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -255,14 +255,14 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @Nullable [] consumed) {
         }
 
         @Override
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
index d01a587..0f85e13 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
@@ -36,8 +36,6 @@
 import android.view.ViewConfiguration;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.NestedScrollingChild3;
 import androidx.core.view.NestedScrollingParent3;
 import androidx.core.view.ViewCompat;
@@ -49,6 +47,8 @@
 import androidx.testutils.MotionEventData;
 import androidx.testutils.SimpleGestureGeneratorKt;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -465,8 +465,8 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
@@ -487,25 +487,25 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
         @Override
-        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-                @Nullable int[] offsetInWindow, int type) {
+        public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+                int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @NonNull [] consumed) {
         }
 
         @Override
         public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
-                @NonNull int[] consumed) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type,
+                int @NonNull [] consumed) {
         }
 
         @Override
@@ -578,7 +578,7 @@
 
         @Override
         public void onNestedPreScroll(
-                @NonNull View target, int dx, int dy, @NonNull int[] consumed) {
+                @NonNull View target, int dx, int dy, int @NonNull [] consumed) {
 
         }
 
@@ -589,7 +589,7 @@
         }
 
         @Override
-        public boolean onNestedPreFling(@NonNull  View target, float velocityX, float velocityY) {
+        public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
             return false;
         }
 
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingFlingTest.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingFlingTest.java
index aaab4b2..97a7a86 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingFlingTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingFlingTest.java
@@ -38,8 +38,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.test.R;
 import androidx.core.view.NestedScrollingParent3;
 import androidx.core.view.ViewCompat;
@@ -50,6 +48,8 @@
 import androidx.testutils.SimpleGestureGeneratorKt;
 
 import org.jetbrains.annotations.NotNull;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -360,14 +360,14 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @Nullable [] consumed) {
         }
 
         @Override
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent2Test.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent2Test.java
index 2358c5c..55d9bee 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent2Test.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent2Test.java
@@ -34,8 +34,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.NestedScrollingChild2;
 import androidx.core.view.NestedScrollingParent2;
 import androidx.core.view.ViewCompat;
@@ -43,6 +41,8 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -565,8 +565,8 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
@@ -587,13 +587,13 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
         @Override
-        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-                @Nullable int[] offsetInWindow, int type) {
+        public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+                int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
@@ -645,40 +645,40 @@
         }
 
         @Override
-        public boolean onStartNestedScroll(@NonNull  View child, @NonNull View target, int axes) {
+        public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes) {
             return false;
         }
 
         @Override
-        public void onNestedScrollAccepted(@NonNull  View child, @NonNull View target, int axes) {
+        public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes) {
 
         }
 
         @Override
-        public void onStopNestedScroll(@NonNull  View target) {
+        public void onStopNestedScroll(@NonNull View target) {
 
         }
 
         @Override
-        public void onNestedScroll(@NonNull  View target, int dxConsumed, int dyConsumed,
+        public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                 int dxUnconsumed, int dyUnconsumed) {
 
         }
 
         @Override
         public void onNestedPreScroll(
-                @NonNull  View target, int dx, int dy, @NonNull int[] consumed) {
+                 @NonNull View target, int dx, int dy, int @NonNull [] consumed) {
 
         }
 
         @Override
-        public boolean onNestedFling(@NonNull  View target, float velocityX, float velocityY,
+        public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY,
                 boolean consumed) {
             return false;
         }
 
         @Override
-        public boolean onNestedPreFling(@NonNull  View target, float velocityX, float velocityY) {
+        public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
             return false;
         }
 
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent3Test.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent3Test.java
index 8dd2de0..0759b03 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent3Test.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingParent3Test.java
@@ -31,8 +31,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.NestedScrollingChild3;
 import androidx.core.view.NestedScrollingParent3;
 import androidx.core.view.ViewCompat;
@@ -40,6 +38,8 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -297,8 +297,8 @@
         }
 
         @Override
-        public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
-                int type) {
+        public void onNestedPreScroll(@NonNull View target, int dx, int dy,
+                int @NonNull [] consumed, int type) {
 
         }
 
@@ -319,25 +319,25 @@
 
         @Override
         public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
         @Override
-        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-                @Nullable int[] offsetInWindow, int type) {
+        public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+                int @Nullable [] offsetInWindow, int type) {
             return false;
         }
 
         @Override
         public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-                int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+                int dxUnconsumed, int dyUnconsumed, int type, int @Nullable [] consumed) {
         }
 
         @Override
         public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-                int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
-                @NonNull int[] consumed) {
+                int dyUnconsumed, int @Nullable [] offsetInWindow, int type,
+                int @NonNull [] consumed) {
         }
     }
 
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
index 2885cc3..83d20ae 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
@@ -28,11 +28,11 @@
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/core/core/src/androidTest/java/androidx/core/widget/TestContentView.java b/core/core/src/androidTest/java/androidx/core/widget/TestContentView.java
index 12ce05c..a303d91 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/TestContentView.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/TestContentView.java
@@ -23,8 +23,8 @@
 import android.util.AttributeSet;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
diff --git a/core/core/src/main/java/android/support/v4/os/ResultReceiver.java b/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
index 9d4db1d..ebe4030 100644
--- a/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
+++ b/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
@@ -25,9 +25,10 @@
 import android.os.Parcelable;
 import android.os.RemoteException;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Generic interface for receiving a callback result from someone.  Use this
  * by creating a subclass and implement {@link #onReceiveResult}, which you can
diff --git a/core/core/src/main/java/androidx/core/accessibilityservice/AccessibilityServiceInfoCompat.java b/core/core/src/main/java/androidx/core/accessibilityservice/AccessibilityServiceInfoCompat.java
index eacb1fc..da2e2718 100644
--- a/core/core/src/main/java/androidx/core/accessibilityservice/AccessibilityServiceInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/accessibilityservice/AccessibilityServiceInfoCompat.java
@@ -22,8 +22,8 @@
 import android.os.Build;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper for accessing features in {@link AccessibilityServiceInfo}.
@@ -195,8 +195,7 @@
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "info.loadDescription(packageManager)")
     @SuppressWarnings("deprecation")
-    @Nullable
-    public static String loadDescription(
+    public static @Nullable String loadDescription(
             @NonNull AccessibilityServiceInfo info, @NonNull PackageManager packageManager) {
         return info.loadDescription(packageManager);
     }
@@ -209,8 +208,7 @@
      * @param feedbackType The feedback type.
      * @return The string representation.
      */
-    @NonNull
-    public static String feedbackTypeToString(int feedbackType) {
+    public static @NonNull String feedbackTypeToString(int feedbackType) {
         StringBuilder builder = new StringBuilder();
         builder.append("[");
         while (feedbackType > 0) {
@@ -249,8 +247,7 @@
      * @param flag The flag.
      * @return The string representation.
      */
-    @Nullable
-    public static String flagToString(int flag) {
+    public static @Nullable String flagToString(int flag) {
         switch (flag) {
             case AccessibilityServiceInfo.DEFAULT:
                 return "DEFAULT";
@@ -297,8 +294,7 @@
      * @param capability The capability.
      * @return The string representation.
      */
-    @NonNull
-    public static String capabilityToString(int capability) {
+    public static @NonNull String capabilityToString(int capability) {
         switch (capability) {
             case CAPABILITY_CAN_RETRIEVE_WINDOW_CONTENT:
                 return "CAPABILITY_CAN_RETRIEVE_WINDOW_CONTENT";
diff --git a/core/core/src/main/java/androidx/core/app/ActivityCompat.java b/core/core/src/main/java/androidx/core/app/ActivityCompat.java
index fd0a998..300112a 100644
--- a/core/core/src/main/java/androidx/core/app/ActivityCompat.java
+++ b/core/core/src/main/java/androidx/core/app/ActivityCompat.java
@@ -39,14 +39,15 @@
 
 import androidx.annotation.IdRes;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.ContextCompat;
 import androidx.core.content.LocusIdCompat;
 import androidx.core.view.DragAndDropPermissionsCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.util.Arrays;
@@ -84,8 +85,8 @@
          *
          * @see #requestPermissions(android.app.Activity, String[], int)
          */
-        void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
-                @NonNull int[] grantResults);
+        void onRequestPermissionsResult(int requestCode, String @NonNull [] permissions,
+                int @NonNull [] grantResults);
     }
 
     /**
@@ -119,7 +120,7 @@
          * @see ActivityCompat#requestPermissions(Activity, String[], int)
          */
         boolean requestPermissions(@NonNull Activity activity,
-                @NonNull String[] permissions, @IntRange(from = 0) int requestCode);
+                String @NonNull [] permissions, @IntRange(from = 0) int requestCode);
 
         /**
          * Determines whether the delegate should handle the permission request as part of
@@ -173,9 +174,8 @@
 
     /**
      */
-    @Nullable
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
-    public static PermissionCompatDelegate getPermissionCompatDelegate() {
+    public static @Nullable PermissionCompatDelegate getPermissionCompatDelegate() {
         return sDelegate;
     }
 
@@ -322,8 +322,7 @@
      * referrer information, applications can spoof it.</p>
      */
     @SuppressWarnings("deprecation")
-    @Nullable
-    public static Uri getReferrer(@NonNull Activity activity) {
+    public static @Nullable Uri getReferrer(@NonNull Activity activity) {
         if (Build.VERSION.SDK_INT >= 22) {
             return Api22Impl.getReferrer(activity);
         }
@@ -356,8 +355,8 @@
      * @see androidx.core.view.ViewCompat#requireViewById(View, int)
      */
     @SuppressWarnings("TypeParameterUnusedInFormals")
-    @NonNull
-    public static <T extends View> T requireViewById(@NonNull Activity activity, @IdRes int id) {
+    public static <T extends View> @NonNull T requireViewById(@NonNull Activity activity,
+            @IdRes int id) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.requireViewById(activity, id);
         }
@@ -501,7 +500,7 @@
      * @see #shouldShowRequestPermissionRationale(android.app.Activity, String)
      */
     public static void requestPermissions(final @NonNull Activity activity,
-            final @NonNull String[] permissions, final @IntRange(from = 0) int requestCode) {
+            final String @NonNull [] permissions, final @IntRange(from = 0) int requestCode) {
         if (sDelegate != null
                 && sDelegate.requestPermissions(activity, permissions, requestCode)) {
             // Delegate has handled the permission request.
@@ -637,8 +636,7 @@
      * URIs. {@code null} if no content URIs are associated with the event or if permissions could
      * not be granted.
      */
-    @Nullable
-    public static DragAndDropPermissionsCompat requestDragAndDropPermissions(
+    public static @Nullable DragAndDropPermissionsCompat requestDragAndDropPermissions(
             @NonNull Activity activity, @NonNull DragEvent dragEvent) {
         return DragAndDropPermissionsCompat.request(activity, dragEvent);
     }
@@ -650,7 +648,7 @@
      *
      * @param activity The activity to recreate
      */
-    public static void recreate(@NonNull final Activity activity) {
+    public static void recreate(final @NonNull Activity activity) {
         if (Build.VERSION.SDK_INT >= 28) {
             // On Android P and later, we can safely rely on the platform recreate()
             activity.recreate();
@@ -699,8 +697,8 @@
      *      <li>API 29 and earlier, this method is no-op.
      * </ul>
      */
-    public static void setLocusContext(@NonNull final Activity activity,
-            @Nullable final LocusIdCompat locusId, @Nullable final Bundle bundle) {
+    public static void setLocusContext(final @NonNull Activity activity,
+            final @Nullable LocusIdCompat locusId, final @Nullable Bundle bundle) {
         if (Build.VERSION.SDK_INT >= 30) {
             Api30Impl.setLocusContext(activity, locusId, bundle);
         }
@@ -765,8 +763,8 @@
             // This class is not instantiable.
         }
 
-        static void setLocusContext(@NonNull final Activity activity,
-                @Nullable final LocusIdCompat locusId, @Nullable final Bundle bundle) {
+        static void setLocusContext(final @NonNull Activity activity,
+                final @Nullable LocusIdCompat locusId, final @Nullable Bundle bundle) {
             activity.setLocusContext(locusId == null ? null : locusId.toLocusId(), bundle);
         }
 
@@ -781,7 +779,7 @@
             // This class is not instantiable.
         }
 
-        static boolean isLaunchedFromBubble(@NonNull final Activity activity)  {
+        static boolean isLaunchedFromBubble(final @NonNull Activity activity)  {
             return activity.isLaunchedFromBubble();
         }
 
diff --git a/core/core/src/main/java/androidx/core/app/ActivityManagerCompat.java b/core/core/src/main/java/androidx/core/app/ActivityManagerCompat.java
index 6afb250..e3dcf71 100644
--- a/core/core/src/main/java/androidx/core/app/ActivityManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/ActivityManagerCompat.java
@@ -18,7 +18,7 @@
 
 import android.app.ActivityManager;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing features in {@link android.app.ActivityManager} in a backwards compatible
diff --git a/core/core/src/main/java/androidx/core/app/ActivityOptionsCompat.java b/core/core/src/main/java/androidx/core/app/ActivityOptionsCompat.java
index f6bcc91..3bed9b4 100644
--- a/core/core/src/main/java/androidx/core/app/ActivityOptionsCompat.java
+++ b/core/core/src/main/java/androidx/core/app/ActivityOptionsCompat.java
@@ -30,11 +30,12 @@
 import android.view.View;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.Pair;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
@@ -83,8 +84,7 @@
      * @return Returns a new ActivityOptions object that you can use to supply
      * these options as the options Bundle when starting an activity.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeCustomAnimation(@NonNull Context context,
+    public static @NonNull ActivityOptionsCompat makeCustomAnimation(@NonNull Context context,
             int enterResId, int exitResId) {
         return new ActivityOptionsCompatImpl(
                 ActivityOptions.makeCustomAnimation(context, enterResId, exitResId));
@@ -110,8 +110,7 @@
      * @return Returns a new ActivityOptions object that you can use to supply
      * these options as the options Bundle when starting an activity.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeScaleUpAnimation(@NonNull View source,
+    public static @NonNull ActivityOptionsCompat makeScaleUpAnimation(@NonNull View source,
             int startX, int startY, int startWidth, int startHeight) {
         return new ActivityOptionsCompatImpl(
                 ActivityOptions.makeScaleUpAnimation(source, startX, startY, startWidth,
@@ -132,8 +131,7 @@
      * @return Returns a new ActivityOptions object that you can use to
      * supply these options as the options Bundle when starting an activity.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeClipRevealAnimation(@NonNull View source,
+    public static @NonNull ActivityOptionsCompat makeClipRevealAnimation(@NonNull View source,
             int startX, int startY, int width, int height) {
         if (Build.VERSION.SDK_INT >= 23) {
             return new ActivityOptionsCompatImpl(
@@ -161,8 +159,7 @@
      * @return Returns a new ActivityOptions object that you can use to supply
      * these options as the options Bundle when starting an activity.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeThumbnailScaleUpAnimation(@NonNull View source,
+    public static @NonNull ActivityOptionsCompat makeThumbnailScaleUpAnimation(@NonNull View source,
             @NonNull Bitmap thumbnail, int startX, int startY) {
         return new ActivityOptionsCompatImpl(
                 ActivityOptions.makeThumbnailScaleUpAnimation(source, thumbnail, startX, startY));
@@ -186,9 +183,9 @@
      * @return Returns a new ActivityOptions object that you can use to
      *         supply these options as the options Bundle when starting an activity.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeSceneTransitionAnimation(@NonNull Activity activity,
-            @NonNull View sharedElement, @NonNull String sharedElementName) {
+    public static @NonNull ActivityOptionsCompat makeSceneTransitionAnimation(
+            @NonNull Activity activity, @NonNull View sharedElement,
+            @NonNull String sharedElementName) {
         return new ActivityOptionsCompatImpl(
                 ActivityOptions.makeSceneTransitionAnimation(activity, sharedElement,
                         sharedElementName));
@@ -211,10 +208,9 @@
      * @return Returns a new ActivityOptions object that you can use to
      *         supply these options as the options Bundle when starting an activity.
      */
-    @NonNull
     @SuppressWarnings("unchecked")
-    public static ActivityOptionsCompat makeSceneTransitionAnimation(@NonNull Activity activity,
-            @Nullable Pair<View, String>... sharedElements) {
+    public static @NonNull ActivityOptionsCompat makeSceneTransitionAnimation(
+            @NonNull Activity activity, Pair<View, String> @Nullable ... sharedElements) {
         android.util.Pair<View, String>[] pairs = null;
         if (sharedElements != null) {
             pairs = new android.util.Pair[sharedElements.length];
@@ -237,8 +233,7 @@
      * {@link android.R.attr#launchMode launchMode} values of
      * <code>singleInstance</code> or <code>singleTask</code>.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeTaskLaunchBehind() {
+    public static @NonNull ActivityOptionsCompat makeTaskLaunchBehind() {
         return new ActivityOptionsCompatImpl(ActivityOptions.makeTaskLaunchBehind());
     }
 
@@ -246,8 +241,7 @@
      * Create a basic ActivityOptions that has no special animation associated with it.
      * Other options can still be set.
      */
-    @NonNull
-    public static ActivityOptionsCompat makeBasic() {
+    public static @NonNull ActivityOptionsCompat makeBasic() {
         if (Build.VERSION.SDK_INT >= 23) {
             return new ActivityOptionsCompatImpl(ActivityOptions.makeBasic());
         }
@@ -282,9 +276,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public ActivityOptionsCompat setLaunchBounds(@Nullable Rect screenSpacePixelRect) {
+        public @NonNull ActivityOptionsCompat setLaunchBounds(@Nullable Rect screenSpacePixelRect) {
             if (Build.VERSION.SDK_INT < 24) {
                 return this;
             }
@@ -300,9 +293,8 @@
             return mActivityOptions.getLaunchBounds();
         }
 
-        @NonNull
         @Override
-        public ActivityOptionsCompat setShareIdentityEnabled(boolean shareIdentity) {
+        public @NonNull ActivityOptionsCompat setShareIdentityEnabled(boolean shareIdentity) {
             if (Build.VERSION.SDK_INT < 34) {
                 return this;
             }
@@ -311,9 +303,8 @@
         }
 
         @SuppressLint("WrongConstant")
-        @NonNull
         @Override
-        public ActivityOptionsCompat setPendingIntentBackgroundActivityStartMode(
+        public @NonNull ActivityOptionsCompat setPendingIntentBackgroundActivityStartMode(
                 @BackgroundActivityStartMode int state) {
             if (Build.VERSION.SDK_INT >= 34) {
                 mActivityOptions.setPendingIntentBackgroundActivityStartMode(state);
@@ -334,9 +325,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public ActivityOptionsCompat setLaunchDisplayId(int launchDisplayId) {
+        public @NonNull ActivityOptionsCompat setLaunchDisplayId(int launchDisplayId) {
             if (Build.VERSION.SDK_INT >= 26) {
                 mActivityOptions.setLaunchDisplayId(launchDisplayId);
             }
@@ -357,8 +347,7 @@
      * {@link android.content.pm.PackageManager#FEATURE_PICTURE_IN_PICTURE} enabled.
      * @param screenSpacePixelRect Launch bounds to use for the activity or null for fullscreen.
      */
-    @NonNull
-    public ActivityOptionsCompat setLaunchBounds(@Nullable Rect screenSpacePixelRect) {
+    public @NonNull ActivityOptionsCompat setLaunchBounds(@Nullable Rect screenSpacePixelRect) {
         return this;
     }
 
@@ -367,8 +356,7 @@
      * @see #setLaunchBounds(Rect)
      * @return Bounds used to launch the activity.
      */
-    @Nullable
-    public Rect getLaunchBounds() {
+    public @Nullable Rect getLaunchBounds() {
         return null;
     }
 
@@ -379,8 +367,7 @@
      * object; you must not modify it, but can supply it to the startActivity
      * methods that take an options Bundle.
      */
-    @Nullable
-    public Bundle toBundle() {
+    public @Nullable Bundle toBundle() {
         return null;
     }
 
@@ -442,8 +429,7 @@
      * @see Activity#getLaunchedFromPackage()
      * @see Activity#getLaunchedFromUid()
      */
-    @NonNull
-    public ActivityOptionsCompat setShareIdentityEnabled(boolean shareIdentity) {
+    public @NonNull ActivityOptionsCompat setShareIdentityEnabled(boolean shareIdentity) {
         return this;
     }
 
@@ -456,8 +442,7 @@
      * {@link ActivityOptions#MODE_BACKGROUND_ACTIVITY_START_ALLOWED} if the PendingIntent is from a
      * trusted source and/or executed on behalf the user.
      */
-    @NonNull
-    public ActivityOptionsCompat setPendingIntentBackgroundActivityStartMode(
+    public @NonNull ActivityOptionsCompat setPendingIntentBackgroundActivityStartMode(
             @BackgroundActivityStartMode int state) {
         return this;
     }
@@ -489,8 +474,7 @@
      * @param launchDisplayId The id of the display where the activity should be launched.
      * @return {@code this} {@link ActivityOptions} instance.
      */
-    @NonNull
-    public ActivityOptionsCompat setLaunchDisplayId(int launchDisplayId) {
+    public @NonNull ActivityOptionsCompat setLaunchDisplayId(int launchDisplayId) {
         return this;
     }
 }
diff --git a/core/core/src/main/java/androidx/core/app/ActivityRecreator.java b/core/core/src/main/java/androidx/core/app/ActivityRecreator.java
index 02f60f9..034feb2 100644
--- a/core/core/src/main/java/androidx/core/app/ActivityRecreator.java
+++ b/core/core/src/main/java/androidx/core/app/ActivityRecreator.java
@@ -30,9 +30,10 @@
 import android.os.Looper;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.List;
@@ -103,7 +104,7 @@
      *
      * @return true if a recreate() task was successfully scheduled.
      */
-    static boolean recreate(@NonNull final Activity activity) {
+    static boolean recreate(final @NonNull Activity activity) {
         // On Android O and later we can rely on the platform recreate()
         if (SDK_INT >= 28) {
             activity.recreate();
diff --git a/core/core/src/main/java/androidx/core/app/AlarmManagerCompat.java b/core/core/src/main/java/androidx/core/app/AlarmManagerCompat.java
index c841e77..ab57c77 100644
--- a/core/core/src/main/java/androidx/core/app/AlarmManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/AlarmManagerCompat.java
@@ -24,9 +24,10 @@
 import android.app.PendingIntent;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Compatibility library for {@link AlarmManager} with fallbacks for older platforms.
  */
diff --git a/core/core/src/main/java/androidx/core/app/AppComponentFactory.java b/core/core/src/main/java/androidx/core/app/AppComponentFactory.java
index d4e620e..3dfb007 100644
--- a/core/core/src/main/java/androidx/core/app/AppComponentFactory.java
+++ b/core/core/src/main/java/androidx/core/app/AppComponentFactory.java
@@ -25,10 +25,11 @@
 import android.content.ContentProvider;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 
 /**
@@ -42,9 +43,8 @@
     /**
      * @see #instantiateActivityCompat
      */
-    @NonNull
     @Override
-    public final Activity instantiateActivity(
+    public final @NonNull Activity instantiateActivity(
             @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(instantiateActivityCompat(cl, className, intent));
@@ -53,9 +53,8 @@
     /**
      * @see #instantiateApplicationCompat
      */
-    @NonNull
     @Override
-    public final Application instantiateApplication(
+    public final @NonNull Application instantiateApplication(
             @NonNull ClassLoader cl, @NonNull String className)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(instantiateApplicationCompat(cl, className));
@@ -64,9 +63,8 @@
     /**
      * @see #instantiateReceiverCompat
      */
-    @NonNull
     @Override
-    public final BroadcastReceiver instantiateReceiver(
+    public final @NonNull BroadcastReceiver instantiateReceiver(
             @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(instantiateReceiverCompat(cl, className, intent));
@@ -75,9 +73,8 @@
     /**
      * @see #instantiateProviderCompat
      */
-    @NonNull
     @Override
-    public final ContentProvider instantiateProvider(
+    public final @NonNull ContentProvider instantiateProvider(
             @NonNull ClassLoader cl, @NonNull String className)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(instantiateProviderCompat(cl, className));
@@ -86,9 +83,8 @@
     /**
      * @see #instantiateServiceCompat
      */
-    @NonNull
     @Override
-    public final Service instantiateService(
+    public final @NonNull Service instantiateService(
             @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(instantiateServiceCompat(cl, className, intent));
diff --git a/core/core/src/main/java/androidx/core/app/AppLaunchChecker.java b/core/core/src/main/java/androidx/core/app/AppLaunchChecker.java
index 65f8974..1a764b9 100644
--- a/core/core/src/main/java/androidx/core/app/AppLaunchChecker.java
+++ b/core/core/src/main/java/androidx/core/app/AppLaunchChecker.java
@@ -23,9 +23,10 @@
 import android.content.SharedPreferences;
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
 import androidx.core.content.IntentCompat;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * This class provides APIs for determining how an app has been launched.
  * This can be useful if you want to confirm that a user has launched your
diff --git a/core/core/src/main/java/androidx/core/app/AppLocalesStorageHelper.java b/core/core/src/main/java/androidx/core/app/AppLocalesStorageHelper.java
index d76b6b2..22c3e0b 100644
--- a/core/core/src/main/java/androidx/core/app/AppLocalesStorageHelper.java
+++ b/core/core/src/main/java/androidx/core/app/AppLocalesStorageHelper.java
@@ -22,9 +22,9 @@
 import android.util.Log;
 import android.util.Xml;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 import org.xmlpull.v1.XmlSerializer;
@@ -54,8 +54,7 @@
     /**
      * Returns app locales after reading from storage, fetched using the application context.
      */
-    @NonNull
-    public static String readLocales(@NonNull Context context) {
+    public static @NonNull String readLocales(@NonNull Context context) {
         synchronized (sAppLocaleStorageSync) {
             String appLocales = "";
 
diff --git a/core/core/src/main/java/androidx/core/app/AppOpsManagerCompat.java b/core/core/src/main/java/androidx/core/app/AppOpsManagerCompat.java
index 9f18fd6..c7eabbd 100644
--- a/core/core/src/main/java/androidx/core/app/AppOpsManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/AppOpsManagerCompat.java
@@ -22,10 +22,11 @@
 import android.content.Context;
 import android.os.Binder;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link android.app.AppOpsManager}.
  */
@@ -72,8 +73,7 @@
      * @param permission The permission.
      * @return The app op associated with the permission or null.
      */
-    @Nullable
-    public static String permissionToOp(@NonNull String permission) {
+    public static @Nullable String permissionToOp(@NonNull String permission) {
         if (SDK_INT >= 23) {
             return Api23Impl.permissionToOp(permission);
         } else {
diff --git a/core/core/src/main/java/androidx/core/app/BundleCompat.java b/core/core/src/main/java/androidx/core/app/BundleCompat.java
index 9be2181..3695143 100644
--- a/core/core/src/main/java/androidx/core/app/BundleCompat.java
+++ b/core/core/src/main/java/androidx/core/app/BundleCompat.java
@@ -19,8 +19,8 @@
 import android.os.Bundle;
 import android.os.IBinder;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper for accessing features in {@link Bundle}.
@@ -42,8 +42,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "bundle.getBinder(key)")
-    @Nullable
-    public static IBinder getBinder(@NonNull Bundle bundle, @Nullable String key) {
+    public static @Nullable IBinder getBinder(@NonNull Bundle bundle, @Nullable String key) {
         return bundle.getBinder(key);
     }
 
diff --git a/core/core/src/main/java/androidx/core/app/CoreComponentFactory.java b/core/core/src/main/java/androidx/core/app/CoreComponentFactory.java
index d20458c..ba5f5cd 100644
--- a/core/core/src/main/java/androidx/core/app/CoreComponentFactory.java
+++ b/core/core/src/main/java/androidx/core/app/CoreComponentFactory.java
@@ -24,11 +24,12 @@
 import android.content.ContentProvider;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Instance of AppComponentFactory for support libraries.
  * @see CompatWrapped
@@ -36,39 +37,37 @@
 @RequiresApi(api = 28)
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
 public class CoreComponentFactory extends AppComponentFactory {
-    @NonNull
     @Override
-    public Activity instantiateActivity(
+    public @NonNull Activity instantiateActivity(
             @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(super.instantiateActivity(cl, className, intent));
     }
 
-    @NonNull
     @Override
-    public Application instantiateApplication(@NonNull ClassLoader cl, @NonNull String className)
+    public @NonNull Application instantiateApplication(@NonNull ClassLoader cl,
+            @NonNull String className)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(super.instantiateApplication(cl, className));
     }
 
-    @NonNull
     @Override
-    public BroadcastReceiver instantiateReceiver(@NonNull ClassLoader cl, @NonNull String className,
+    public @NonNull BroadcastReceiver instantiateReceiver(@NonNull ClassLoader cl,
+            @NonNull String className,
             @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(super.instantiateReceiver(cl, className, intent));
     }
 
-    @NonNull
     @Override
-    public ContentProvider instantiateProvider(@NonNull ClassLoader cl, @NonNull String className)
+    public @NonNull ContentProvider instantiateProvider(@NonNull ClassLoader cl,
+            @NonNull String className)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(super.instantiateProvider(cl, className));
     }
 
-    @NonNull
     @Override
-    public Service instantiateService(
+    public @NonNull Service instantiateService(
             @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent)
             throws InstantiationException, IllegalAccessException, ClassNotFoundException {
         return checkCompatWrapper(super.instantiateService(cl, className, intent));
diff --git a/core/core/src/main/java/androidx/core/app/DialogCompat.java b/core/core/src/main/java/androidx/core/app/DialogCompat.java
index 0d195ae..809012f 100644
--- a/core/core/src/main/java/androidx/core/app/DialogCompat.java
+++ b/core/core/src/main/java/androidx/core/app/DialogCompat.java
@@ -20,9 +20,10 @@
 import android.os.Build;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link android.app.Dialog} in a backwards compatible
  * fashion.
@@ -51,8 +52,7 @@
      * @see Dialog#requireViewById(int)
      * @see Dialog#findViewById(int)
      */
-    @NonNull
-    public static View requireViewById(@NonNull Dialog dialog, int id) {
+    public static @NonNull View requireViewById(@NonNull Dialog dialog, int id) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.requireViewById(dialog, id);
         } else {
diff --git a/core/core/src/main/java/androidx/core/app/FrameMetricsAggregator.java b/core/core/src/main/java/androidx/core/app/FrameMetricsAggregator.java
index dace804..561f194 100644
--- a/core/core/src/main/java/androidx/core/app/FrameMetricsAggregator.java
+++ b/core/core/src/main/java/androidx/core/app/FrameMetricsAggregator.java
@@ -27,11 +27,12 @@
 import android.view.Window;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.ref.WeakReference;
@@ -237,8 +238,7 @@
      * the {@code [TOTAL_INDEX]} item.
      * @see #getMetrics()
      */
-    @Nullable
-    public SparseIntArray[] remove(@NonNull Activity activity) {
+    public SparseIntArray @Nullable [] remove(@NonNull Activity activity) {
         return mInstance.remove(activity);
     }
 
@@ -254,8 +254,7 @@
      * @see #remove(Activity)
      * @see #getMetrics()
      */
-    @Nullable
-    public SparseIntArray[] stop() {
+    public SparseIntArray @Nullable [] stop() {
         return mInstance.stop();
     }
 
@@ -267,8 +266,7 @@
      * the {@code [TOTAL_INDEX]} item.
      * @see #getMetrics()
      */
-    @Nullable
-    public SparseIntArray[] reset() {
+    public SparseIntArray @Nullable [] reset() {
         return mInstance.reset();
     }
 
@@ -295,8 +293,7 @@
      * SparseIntArray object, e.g., data for {@code TOTAL_DURATION} is stored in
      * the {@code [TOTAL_INDEX]} item.
      */
-    @Nullable
-    public SparseIntArray[] getMetrics() {
+    public SparseIntArray @Nullable [] getMetrics() {
         return mInstance.getMetrics();
     }
 
diff --git a/core/core/src/main/java/androidx/core/app/GrammaticalInflectionManagerCompat.java b/core/core/src/main/java/androidx/core/app/GrammaticalInflectionManagerCompat.java
index a95e565..7d1da89 100644
--- a/core/core/src/main/java/androidx/core/app/GrammaticalInflectionManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/GrammaticalInflectionManagerCompat.java
@@ -22,11 +22,12 @@
 
 import androidx.annotation.AnyThread;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.OptIn;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/app/JobIntentService.java b/core/core/src/main/java/androidx/core/app/JobIntentService.java
index c143946..2feaa78 100644
--- a/core/core/src/main/java/androidx/core/app/JobIntentService.java
+++ b/core/core/src/main/java/androidx/core/app/JobIntentService.java
@@ -30,10 +30,11 @@
 import android.os.PowerManager;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 
diff --git a/core/core/src/main/java/androidx/core/app/LocaleManagerCompat.java b/core/core/src/main/java/androidx/core/app/LocaleManagerCompat.java
index 91ac468..7242f56 100644
--- a/core/core/src/main/java/androidx/core/app/LocaleManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/LocaleManagerCompat.java
@@ -24,11 +24,12 @@
 import android.os.LocaleList;
 
 import androidx.annotation.AnyThread;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.os.LocaleListCompat;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Locale;
 
 /**
@@ -47,9 +48,8 @@
      * is set, this method helps cater to rare use-cases which might require specifically knowing
      * the system locale.
      */
-    @NonNull
     @AnyThread
-    public static LocaleListCompat getSystemLocales(@NonNull Context context) {
+    public static @NonNull LocaleListCompat getSystemLocales(@NonNull Context context) {
         LocaleListCompat systemLocales = LocaleListCompat.getEmptyLocaleList();
         // TODO: modify the check to Build.Version.SDK_INT >= 33.
         if (Build.VERSION.SDK_INT >= 33) {
@@ -74,8 +74,7 @@
      * set.
      */
     @AnyThread
-    @NonNull
-    public static LocaleListCompat getApplicationLocales(@NonNull Context context) {
+    public static @NonNull LocaleListCompat getApplicationLocales(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 33) {
             // If the API version is 33 or above we want to redirect the call to the framework API.
             Object localeManager = getLocaleManagerForApplication(context);
diff --git a/core/core/src/main/java/androidx/core/app/NavUtils.java b/core/core/src/main/java/androidx/core/app/NavUtils.java
index 56fffc4..e08c4d8 100644
--- a/core/core/src/main/java/androidx/core/app/NavUtils.java
+++ b/core/core/src/main/java/androidx/core/app/NavUtils.java
@@ -26,8 +26,8 @@
 import android.os.Build;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * NavUtils provides helper functionality for applications implementing
@@ -118,8 +118,7 @@
      * @param sourceActivity Activity to fetch a parent intent for
      * @return a new Intent targeting the defined parent activity of sourceActivity
      */
-    @Nullable
-    public static Intent getParentActivityIntent(@NonNull Activity sourceActivity) {
+    public static @Nullable Intent getParentActivityIntent(@NonNull Activity sourceActivity) {
         // Prefer the "real" JB definition, else fall back to the meta-data element.
         Intent result = sourceActivity.getParentActivityIntent();
         if (result != null) {
@@ -153,8 +152,7 @@
      * @return a new Intent targeting the defined parent activity of sourceActivity
      * @throws NameNotFoundException if the ComponentName for sourceActivityClass is invalid
      */
-    @Nullable
-    public static Intent getParentActivityIntent(@NonNull Context context,
+    public static @Nullable Intent getParentActivityIntent(@NonNull Context context,
             @NonNull Class<?> sourceActivityClass)
             throws NameNotFoundException {
         String parentActivity = getParentActivityName(context,
@@ -180,8 +178,7 @@
      * @return a new Intent targeting the defined parent activity of sourceActivity
      * @throws NameNotFoundException if the ComponentName for sourceActivityClass is invalid
      */
-    @Nullable
-    public static Intent getParentActivityIntent(@NonNull Context context,
+    public static @Nullable Intent getParentActivityIntent(@NonNull Context context,
             @NonNull ComponentName componentName)
             throws NameNotFoundException {
         String parentActivity = getParentActivityName(context, componentName);
@@ -206,8 +203,7 @@
      * @return The fully qualified class name of sourceActivity's parent activity or null if
      *         it was not specified
      */
-    @Nullable
-    public static String getParentActivityName(@NonNull Activity sourceActivity) {
+    public static @Nullable String getParentActivityName(@NonNull Activity sourceActivity) {
         try {
             return getParentActivityName(sourceActivity, sourceActivity.getComponentName());
         } catch (NameNotFoundException e) {
@@ -225,8 +221,7 @@
      * @return The fully qualified class name of sourceActivity's parent activity or null if
      *         it was not specified
      */
-    @Nullable
-    public static String getParentActivityName(@NonNull Context context,
+    public static @Nullable String getParentActivityName(@NonNull Context context,
             @NonNull ComponentName componentName)
             throws NameNotFoundException {
         PackageManager pm = context.getPackageManager();
diff --git a/core/core/src/main/java/androidx/core/app/NotificationChannelCompat.java b/core/core/src/main/java/androidx/core/app/NotificationChannelCompat.java
index af5365c..3c74502 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationChannelCompat.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationChannelCompat.java
@@ -25,12 +25,13 @@
 import android.os.Build;
 import android.provider.Settings;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.content.pm.ShortcutInfoCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A representation of settings that apply to a collection of similarly themed notifications.
  *
@@ -50,8 +51,7 @@
     private static final int DEFAULT_LIGHT_COLOR = 0;
 
     // These fields are settable through the builder
-    @NonNull
-    final String mId;
+    final @NonNull String mId;
     CharSequence mName;
     int mImportance;
     String mDescription;
@@ -99,8 +99,7 @@
          * <p>The recommended maximum length is 40 characters; the value may be truncated if it
          * is too long.
          */
-        @NonNull
-        public Builder setName(@Nullable CharSequence name) {
+        public @NonNull Builder setName(@Nullable CharSequence name) {
             mChannel.mName = name;
             return this;
         }
@@ -114,8 +113,7 @@
          * @param importance the amount the user should be interrupted by notifications from this
          *                   channel.
          */
-        @NonNull
-        public Builder setImportance(int importance) {
+        public @NonNull Builder setImportance(int importance) {
             mChannel.mImportance = importance;
             return this;
         }
@@ -126,8 +124,7 @@
          * <p>The recommended maximum length is 300 characters; the value may be truncated if it is
          * too long.
          */
-        @NonNull
-        public Builder setDescription(@Nullable String description) {
+        public @NonNull Builder setDescription(@Nullable String description) {
             mChannel.mDescription = description;
             return this;
         }
@@ -144,8 +141,7 @@
          * @param groupId the id of a group created by
          *                {@link NotificationManagerCompat#createNotificationChannelGroup}.
          */
-        @NonNull
-        public Builder setGroup(@Nullable String groupId) {
+        public @NonNull Builder setGroup(@Nullable String groupId) {
             mChannel.mGroupId = groupId;
             return this;
         }
@@ -159,8 +155,7 @@
          *
          * @param showBadge true if badges should be allowed to be shown.
          */
-        @NonNull
-        public Builder setShowBadge(boolean showBadge) {
+        public @NonNull Builder setShowBadge(boolean showBadge) {
             mChannel.mShowBadge = showBadge;
             return this;
         }
@@ -174,8 +169,8 @@
          * Only modifiable before the channel is submitted to
          * {@link NotificationManagerCompat#createNotificationChannel(NotificationChannelCompat)}.
          */
-        @NonNull
-        public Builder setSound(@Nullable Uri sound, @Nullable AudioAttributes audioAttributes) {
+        public @NonNull Builder setSound(@Nullable Uri sound,
+                @Nullable AudioAttributes audioAttributes) {
             mChannel.mSound = sound;
             mChannel.mAudioAttributes = audioAttributes;
             return this;
@@ -188,8 +183,7 @@
          * Only modifiable before the channel is submitted to
          * {@link NotificationManagerCompat#createNotificationChannel(NotificationChannelCompat)}.
          */
-        @NonNull
-        public Builder setLightsEnabled(boolean lights) {
+        public @NonNull Builder setLightsEnabled(boolean lights) {
             mChannel.mLights = lights;
             return this;
         }
@@ -202,8 +196,7 @@
          * Only modifiable before the channel is submitted to
          * {@link NotificationManagerCompat#createNotificationChannel(NotificationChannelCompat)}.
          */
-        @NonNull
-        public Builder setLightColor(int argb) {
+        public @NonNull Builder setLightColor(int argb) {
             mChannel.mLightColor = argb;
             return this;
         }
@@ -215,8 +208,7 @@
          * Only modifiable before the channel is submitted to
          * {@link NotificationManagerCompat#createNotificationChannel(NotificationChannelCompat)}.
          */
-        @NonNull
-        public Builder setVibrationEnabled(boolean vibration) {
+        public @NonNull Builder setVibrationEnabled(boolean vibration) {
             mChannel.mVibrationEnabled = vibration;
             return this;
         }
@@ -229,8 +221,7 @@
          * Only modifiable before the channel is submitted to
          * {@link NotificationManagerCompat#createNotificationChannel(NotificationChannelCompat)}.
          */
-        @NonNull
-        public Builder setVibrationPattern(@Nullable long[] vibrationPattern) {
+        public @NonNull Builder setVibrationPattern(long @Nullable [] vibrationPattern) {
             mChannel.mVibrationEnabled = vibrationPattern != null && vibrationPattern.length > 0;
             mChannel.mVibrationPattern = vibrationPattern;
             return this;
@@ -252,8 +243,7 @@
          * @param conversationId  The {@link ShortcutInfoCompat#getId()} of the shortcut
          *                        representing this channel's conversation.
          */
-        @NonNull
-        public Builder setConversationId(@NonNull String parentChannelId,
+        public @NonNull Builder setConversationId(@NonNull String parentChannelId,
                 @NonNull String conversationId) {
             if (Build.VERSION.SDK_INT >= 30) {
                 mChannel.mParentId = parentChannelId;
@@ -265,8 +255,7 @@
         /**
          * Creates a {@link NotificationChannelCompat} instance.
          */
-        @NonNull
-        public NotificationChannelCompat build() {
+        public @NonNull NotificationChannelCompat build() {
             return mChannel;
         }
     }
@@ -335,8 +324,7 @@
     /**
      * Creates a {@link Builder} instance with all the writeable property values of this instance.
      */
-    @NonNull
-    public Builder toBuilder() {
+    public @NonNull Builder toBuilder() {
         return new Builder(mId, mImportance)
                 .setName(mName)
                 .setDescription(mDescription)
@@ -353,24 +341,21 @@
     /**
      * Returns the id of this channel.
      */
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
     /**
      * Returns the user visible name of this channel.
      */
-    @Nullable
-    public CharSequence getName() {
+    public @Nullable CharSequence getName() {
         return mName;
     }
 
     /**
      * Returns the user visible description of this channel.
      */
-    @Nullable
-    public String getDescription() {
+    public @Nullable String getDescription() {
         return mDescription;
     }
 
@@ -390,16 +375,14 @@
     /**
      * Returns the notification sound for this channel.
      */
-    @Nullable
-    public Uri getSound() {
+    public @Nullable Uri getSound() {
         return mSound;
     }
 
     /**
      * Returns the audio attributes for sound played by notifications posted to this channel.
      */
-    @Nullable
-    public AudioAttributes getAudioAttributes() {
+    public @Nullable AudioAttributes getAudioAttributes() {
         return mAudioAttributes;
     }
 
@@ -429,8 +412,7 @@
      * Returns the vibration pattern for notifications posted to this channel. Will be ignored if
      * vibration is not enabled ({@link #shouldVibrate()}.
      */
-    @Nullable
-    public long[] getVibrationPattern() {
+    public long @Nullable [] getVibrationPattern() {
         return mVibrationPattern;
     }
 
@@ -449,8 +431,7 @@
      *
      * This is used only for visually grouping channels in the UI.
      */
-    @Nullable
-    public String getGroup() {
+    public @Nullable String getGroup() {
         return mGroupId;
     }
 
@@ -459,8 +440,7 @@
      * a conversation related channel.
      * See {@link Builder#setConversationId(String, String)}.
      */
-    @Nullable
-    public String getParentChannelId() {
+    public @Nullable String getParentChannelId() {
         return mParentId;
     }
 
@@ -469,8 +449,7 @@
      * if it's associated with a conversation.
      * See {@link Builder#setConversationId(String, String)}.
      */
-    @Nullable
-    public String getConversationId() {
+    public @Nullable String getConversationId() {
         return mConversationId;
     }
 
diff --git a/core/core/src/main/java/androidx/core/app/NotificationChannelGroupCompat.java b/core/core/src/main/java/androidx/core/app/NotificationChannelGroupCompat.java
index ea2ca37..353ff75 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationChannelGroupCompat.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationChannelGroupCompat.java
@@ -21,11 +21,12 @@
 import android.content.Intent;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -72,8 +73,7 @@
          * <p>The recommended maximum length is 40 characters; the value may be truncated if it
          * is too long.
          */
-        @NonNull
-        public Builder setName(@Nullable CharSequence name) {
+        public @NonNull Builder setName(@Nullable CharSequence name) {
             mGroup.mName = name;
             return this;
         }
@@ -85,8 +85,7 @@
          * is too
          * long.
          */
-        @NonNull
-        public Builder setDescription(@Nullable String description) {
+        public @NonNull Builder setDescription(@Nullable String description) {
             mGroup.mDescription = description;
             return this;
         }
@@ -94,8 +93,7 @@
         /**
          * Creates a {@link NotificationChannelGroupCompat} instance.
          */
-        @NonNull
-        public NotificationChannelGroupCompat build() {
+        public @NonNull NotificationChannelGroupCompat build() {
             return mGroup;
         }
     }
@@ -159,8 +157,7 @@
     /**
      * Creates a {@link Builder} instance with all the writeable property values of this instance.
      */
-    @NonNull
-    public Builder toBuilder() {
+    public @NonNull Builder toBuilder() {
         return new Builder(mId)
                 .setName(mName)
                 .setDescription(mDescription);
@@ -169,24 +166,21 @@
     /**
      * Gets the id of the group.
      */
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
     /**
      * Gets the user visible name of the group.
      */
-    @Nullable
-    public CharSequence getName() {
+    public @Nullable CharSequence getName() {
         return mName;
     }
 
     /**
      * Gets the user visible description of the group.
      */
-    @Nullable
-    public String getDescription() {
+    public @Nullable String getDescription() {
         return mDescription;
     }
 
@@ -211,8 +205,7 @@
      * <p>This is a read-only property which is only valid on instances fetched from the
      * {@link NotificationManagerCompat}.
      */
-    @NonNull
-    public List<NotificationChannelCompat> getChannels() {
+    public @NonNull List<NotificationChannelCompat> getChannels() {
         return mChannels;
     }
 
diff --git a/core/core/src/main/java/androidx/core/app/NotificationCompat.java b/core/core/src/main/java/androidx/core/app/NotificationCompat.java
index d1317ab..2459494 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationCompat.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationCompat.java
@@ -64,8 +64,6 @@
 import androidx.annotation.DimenRes;
 import androidx.annotation.Dimension;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.R;
@@ -76,6 +74,9 @@
 import androidx.core.text.BidiFormatter;
 import androidx.core.view.GravityCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.text.NumberFormat;
@@ -1020,8 +1021,7 @@
         public ArrayList<Action> mActions = new ArrayList<>();
 
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @NonNull
-        public ArrayList<Person> mPersonList = new ArrayList<>();
+        public @NonNull ArrayList<Person> mPersonList = new ArrayList<>();
 
         // Invisible actions are stored in the CarExtender bundle without actually being owned by
         // CarExtender. This is to comply with an optimization of the Android OS which removes
@@ -1197,8 +1197,7 @@
         }
 
         /** Remove all extras which have been parsed by the rest of the copy process */
-        @Nullable
-        private static Bundle getExtrasWithoutDuplicateData(
+        private static @Nullable Bundle getExtrasWithoutDuplicateData(
                 @NonNull Notification notification, @Nullable Style style) {
             if (notification.extras == null) {
                 return null;
@@ -1439,8 +1438,7 @@
          *
          * <p>Prior to {@link Build.VERSION_CODES#O} this field has no effect.
          */
-        @NonNull
-        public Builder setSettingsText(@Nullable CharSequence text) {
+        public @NonNull Builder setSettingsText(@Nullable CharSequence text) {
             mSettingsText = limitCharSequenceLength(text);
             return this;
         }
@@ -1459,7 +1457,7 @@
          * <p>Note: The reply text will only be shown on notifications that have least one action
          * with a {@code RemoteInput}.</p>
          */
-        public @NonNull Builder setRemoteInputHistory(@Nullable CharSequence[] text) {
+        public @NonNull Builder setRemoteInputHistory(CharSequence @Nullable [] text) {
             mRemoteInputHistory = text;
             return this;
         }
@@ -1554,8 +1552,7 @@
          *          even if other notifications are suppressed.
          */
         @SuppressWarnings("deprecation")
-        @NonNull
-        public Builder setFullScreenIntent(@Nullable PendingIntent intent,
+        public @NonNull Builder setFullScreenIntent(@Nullable PendingIntent intent,
                 boolean highPriority) {
             mFullScreenIntent = intent;
             setFlag(FLAG_HIGH_PRIORITY, highPriority);
@@ -1686,7 +1683,7 @@
          * @see android.os.Vibrator for a discussion of the <code>pattern</code>
          * parameter.
          */
-        public @NonNull Builder setVibrate(@Nullable long[] pattern) {
+        public @NonNull Builder setVibrate(long @Nullable [] pattern) {
             mNotification.vibrate = pattern;
             return this;
         }
@@ -1910,7 +1907,7 @@
          * @param person the person to add.
          * @see #EXTRA_PEOPLE_LIST
          */
-        public @NonNull Builder addPerson(@Nullable final Person person) {
+        public @NonNull Builder addPerson(final @Nullable Person person) {
             if (person != null) {
                 mPersonList.add(person);
             }
@@ -2086,8 +2083,7 @@
          * @param intent {@link android.app.PendingIntent} to be fired when the action is invoked.
          */
         @RequiresApi(21)
-        @NonNull
-        public Builder addInvisibleAction(int icon, @Nullable CharSequence title,
+        public @NonNull Builder addInvisibleAction(int icon, @Nullable CharSequence title,
                 @Nullable PendingIntent intent) {
             mInvisibleActions.add(new Action(icon, title, intent));
             return this;
@@ -2395,7 +2391,7 @@
          * {@link #setContentTitle(CharSequence) contentTitle} if they were empty.
          *
          */
-        public @NonNull Builder setShortcutInfo(@Nullable final ShortcutInfoCompat shortcutInfo) {
+        public @NonNull Builder setShortcutInfo(final @Nullable ShortcutInfoCompat shortcutInfo) {
             // TODO: b/156784300 add check to filter long-lived and sharing shortcut
             if (shortcutInfo == null) {
                 return this;
@@ -2423,7 +2419,7 @@
          * {@link android.view.contentcapture.ContentCaptureContext}) so the device's intelligence
          * services can correlate them.
          */
-        public @NonNull Builder setLocusId(@Nullable final LocusIdCompat locusId) {
+        public @NonNull Builder setLocusId(final @Nullable LocusIdCompat locusId) {
             mLocusId = locusId;
             return this;
         }
@@ -2476,8 +2472,8 @@
          * {@link android.os.Build.VERSION_CODES#S}.
          */
         @SuppressWarnings("MissingGetterMatchingBuilder") // no underlying getter in platform API
-        @NonNull
-        public Builder setForegroundServiceBehavior(@ServiceNotificationBehavior int behavior) {
+        public @NonNull Builder setForegroundServiceBehavior(
+                @ServiceNotificationBehavior int behavior) {
             mFgsDeferBehavior = behavior;
             return this;
         }
@@ -2726,9 +2722,8 @@
 
         /**
          */
-        @Nullable
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        protected String getClassName() {
+        protected @Nullable String getClassName() {
             // We can't crash for apps that write their own subclasses, so we return null
             return null;
         }
@@ -2818,8 +2813,8 @@
         /**
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @Nullable
-        public static Style extractStyleFromNotification(@NonNull Notification notification) {
+        public static @Nullable Style extractStyleFromNotification(
+                @NonNull Notification notification) {
             Bundle extras = NotificationCompat.getExtras(notification);
             if (extras == null) {
                 return null;
@@ -2827,8 +2822,7 @@
             return constructStyleForExtras(extras);
         }
 
-        @Nullable
-        private static Style constructCompatStyleByPlatformName(
+        private static @Nullable Style constructCompatStyleByPlatformName(
                 @Nullable String platformTemplateClass) {
             if (platformTemplateClass == null) {
                 return null;
@@ -2854,8 +2848,7 @@
             return null;
         }
 
-        @Nullable
-        static Style constructCompatStyleByName(@Nullable String templateClass) {
+        static @Nullable Style constructCompatStyleByName(@Nullable String templateClass) {
             if (templateClass != null) {
                 switch (templateClass) {
                     case BigTextStyle.TEMPLATE_CLASS_NAME:
@@ -2875,8 +2868,7 @@
             return null;
         }
 
-        @Nullable
-        static Style constructCompatStyleForBundle(@NonNull Bundle extras) {
+        static @Nullable Style constructCompatStyleForBundle(@NonNull Bundle extras) {
             // If the compat template name provided in the bundle can be resolved to a class, use
             // that style class.
             Style style = constructCompatStyleByName(extras.getString(EXTRA_COMPAT_TEMPLATE));
@@ -2903,8 +2895,7 @@
             return constructCompatStyleByPlatformName(extras.getString(EXTRA_TEMPLATE));
         }
 
-        @Nullable
-        static Style constructStyleForExtras(@NonNull Bundle extras) {
+        static @Nullable Style constructStyleForExtras(@NonNull Bundle extras) {
             final Style style = constructCompatStyleForBundle(extras);
             if (style == null) {
                 return null;
@@ -2920,8 +2911,7 @@
         /**
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @NonNull
-        public RemoteViews applyStandardTemplate(boolean showSmallIcon,
+        public @NonNull RemoteViews applyStandardTemplate(boolean showSmallIcon,
                 int resId, boolean fitIn1U) {
             Resources res = mBuilder.mContext.getResources();
             RemoteViews contentView = new RemoteViews(mBuilder.mContext.getPackageName(), resId);
@@ -3237,8 +3227,7 @@
          * Set the content description of the big picture.
          */
         @RequiresApi(31)
-        @NonNull
-        public BigPictureStyle setContentDescription(
+        public @NonNull BigPictureStyle setContentDescription(
                 @Nullable CharSequence contentDescription) {
             mPictureContentDescription = contentDescription;
             return this;
@@ -3268,8 +3257,7 @@
          * state of this notification.
          */
         @RequiresApi(31)
-        @NonNull
-        public BigPictureStyle showBigPictureWhenCollapsed(boolean show) {
+        public @NonNull BigPictureStyle showBigPictureWhenCollapsed(boolean show) {
             mShowBigPictureWhenCollapsed = show;
             return this;
         }
@@ -3297,8 +3285,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -3373,8 +3360,7 @@
         /**
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @Nullable
-        public static IconCompat getPictureIcon(@Nullable Bundle extras) {
+        public static @Nullable IconCompat getPictureIcon(@Nullable Bundle extras) {
             if (extras == null) return null;
             // When this style adds a picture, we only add one of the keys.  If both were added,
             // it would most likely be a legacy app trying to override the picture in some way.
@@ -3387,8 +3373,7 @@
             }
         }
 
-        @Nullable
-        private static IconCompat asIconCompat(@Nullable Parcelable bitmapOrIcon) {
+        private static @Nullable IconCompat asIconCompat(@Nullable Parcelable bitmapOrIcon) {
             if (bitmapOrIcon != null) {
                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                     if (bitmapOrIcon instanceof Icon) {
@@ -3537,8 +3522,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -3677,8 +3661,7 @@
          * @deprecated Use {@link #getUser()} instead.
          */
         @Deprecated
-        @Nullable
-        public CharSequence getUserDisplayName() {
+        public @Nullable CharSequence getUserDisplayName() {
             return mUser.getName();
         }
 
@@ -3710,8 +3693,7 @@
         /**
          * Return the title to be displayed on this conversation. Can be {@code null}.
          */
-        @Nullable
-        public CharSequence getConversationTitle() {
+        public @Nullable CharSequence getConversationTitle() {
             return mConversationTitle;
         }
 
@@ -3734,8 +3716,7 @@
          * {@link #addMessage(Message)}
          */
         @Deprecated
-        @NonNull
-        public MessagingStyle addMessage(@Nullable CharSequence text, long timestamp,
+        public @NonNull MessagingStyle addMessage(@Nullable CharSequence text, long timestamp,
                 @Nullable CharSequence sender) {
             mMessages.add(
                     new Message(text, timestamp, new Person.Builder().setName(sender).build()));
@@ -3869,8 +3850,7 @@
          * @return {@code null} if there is no {@link MessagingStyle} set, or if the SDK version is
          * &lt; {@code 16} (JellyBean).
          */
-        @Nullable
-        public static MessagingStyle extractMessagingStyleFromNotification(
+        public static @Nullable MessagingStyle extractMessagingStyleFromNotification(
                 @NonNull Notification notification) {
             Style style = Style.extractStyleFromNotification(notification);
             if (style instanceof MessagingStyle) {
@@ -3883,8 +3863,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -3980,8 +3959,7 @@
             }
         }
 
-        @Nullable
-        private Message findLatestIncomingMessage() {
+        private @Nullable Message findLatestIncomingMessage() {
             for (int i = mMessages.size() - 1; i >= 0; i--) {
                 Message message = mMessages.get(i);
                 // Incoming messages have a non-empty sender.
@@ -4031,8 +4009,7 @@
             return sb;
         }
 
-        @NonNull
-        private TextAppearanceSpan makeFontColorSpan(int color) {
+        private @NonNull TextAppearanceSpan makeFontColorSpan(int color) {
             return new TextAppearanceSpan(null, 0, 0, ColorStateList.valueOf(color), null);
         }
 
@@ -4122,11 +4099,11 @@
 
             private final CharSequence mText;
             private final long mTimestamp;
-            @Nullable private final Person mPerson;
+            private final @Nullable Person mPerson;
 
             private Bundle mExtras = new Bundle();
-            @Nullable private String mDataMimeType;
-            @Nullable private Uri mDataUri;
+            private @Nullable String mDataMimeType;
+            private @Nullable Uri mDataUri;
 
             /**
              * Creates a new {@link Message} with the given text, timestamp, and sender.
@@ -4203,8 +4180,7 @@
              * Get the text to be used for this message, or the fallback text if a type and content
              * Uri have been set
              */
-            @Nullable
-            public CharSequence getText() {
+            public @Nullable CharSequence getText() {
                 return mText;
             }
 
@@ -4214,8 +4190,7 @@
             }
 
             /** Get the extras Bundle for this message. */
-            @NonNull
-            public Bundle getExtras() {
+            public @NonNull Bundle getExtras() {
                 return mExtras;
             }
 
@@ -4225,20 +4200,17 @@
              * @deprecated Use {@link #getPerson()}
              */
             @Deprecated
-            @Nullable
-            public CharSequence getSender() {
+            public @Nullable CharSequence getSender() {
                 return mPerson == null ? null : mPerson.getName();
             }
 
             /** Returns the {@link Person} sender of this message. */
-            @Nullable
-            public Person getPerson() {
+            public @Nullable Person getPerson() {
                 return mPerson;
             }
 
             /** Get the MIME type of the data pointed to by the URI. */
-            @Nullable
-            public String getDataMimeType() {
+            public @Nullable String getDataMimeType() {
                 return mDataMimeType;
             }
 
@@ -4246,8 +4218,7 @@
              * Get the the Uri pointing to the content of the message. Can be null, in which case
              * {@see #getText()} is used.
              */
-            @Nullable
-            public Uri getDataUri() {
+            public @Nullable Uri getDataUri() {
                 return mDataUri;
             }
 
@@ -4282,8 +4253,7 @@
                 return bundle;
             }
 
-            @NonNull
-            static Bundle[] getBundleArrayForMessages(@NonNull List<Message> messages) {
+            static Bundle @NonNull [] getBundleArrayForMessages(@NonNull List<Message> messages) {
                 Bundle[] bundles = new Bundle[messages.size()];
                 final int N = messages.size();
                 for (int i = 0; i < N; i++) {
@@ -4292,8 +4262,8 @@
                 return bundles;
             }
 
-            @NonNull
-            static List<Message> getMessagesFromBundleArray(@NonNull Parcelable[] bundles) {
+            static @NonNull List<Message> getMessagesFromBundleArray(
+                    Parcelable @NonNull [] bundles) {
                 List<Message> messages = new ArrayList<>(bundles.length);
                 for (int i = 0; i < bundles.length; i++) {
                     if (bundles[i] instanceof Bundle) {
@@ -4306,9 +4276,8 @@
                 return messages;
             }
 
-            @Nullable
             @SuppressWarnings("deprecation")
-            static Message getMessageFromBundle(@NonNull Bundle bundle) {
+            static @Nullable Message getMessageFromBundle(@NonNull Bundle bundle) {
                 try {
                     if (!bundle.containsKey(KEY_TEXT) || !bundle.containsKey(KEY_TIMESTAMP)) {
                         return null;
@@ -4354,9 +4323,8 @@
              * {@link Notification.MessagingStyle.Message}.
              */
             @RestrictTo(LIBRARY_GROUP_PREFIX)
-            @NonNull
             @RequiresApi(24)
-            Notification.MessagingStyle.Message toAndroidMessage() {
+            Notification.MessagingStyle.@NonNull Message toAndroidMessage() {
                 Notification.MessagingStyle.Message frameworkMessage;
                 Person person = getPerson();
                 // Use Person for P and above
@@ -4590,8 +4558,7 @@
          * @param declineIntent the intent to be sent when the user taps the decline action
          * @param answerIntent  the intent to be sent when the user taps the answer action
          */
-        @NonNull
-        public static CallStyle forIncomingCall(@NonNull Person person,
+        public static @NonNull CallStyle forIncomingCall(@NonNull Person person,
                 @NonNull PendingIntent declineIntent, @NonNull PendingIntent answerIntent) {
             return new CallStyle(CALL_TYPE_INCOMING, person,
                     null /* hangUpIntent */,
@@ -4610,8 +4577,7 @@
          *                     the person also needs to have a non-empty name associated with it
          * @param hangUpIntent the intent to be sent when the user taps the hang up action
          */
-        @NonNull
-        public static CallStyle forOngoingCall(@NonNull Person person,
+        public static @NonNull CallStyle forOngoingCall(@NonNull Person person,
                 @NonNull PendingIntent hangUpIntent) {
             return new CallStyle(CALL_TYPE_ONGOING, person,
                     requireNonNull(hangUpIntent, "hangUpIntent is required"),
@@ -4632,8 +4598,7 @@
          * @param hangUpIntent the intent to be sent when the user taps the hang up action
          * @param answerIntent the intent to be sent when the user taps the answer action
          */
-        @NonNull
-        public static CallStyle forScreeningCall(@NonNull Person person,
+        public static @NonNull CallStyle forScreeningCall(@NonNull Person person,
                 @NonNull PendingIntent hangUpIntent, @NonNull PendingIntent answerIntent) {
             return new CallStyle(CALL_TYPE_SCREENING, person,
                     requireNonNull(hangUpIntent, "hangUpIntent is required"),
@@ -4667,8 +4632,7 @@
          * Sets whether the call is a video call, which may affect the icons or text used on the
          * required action buttons.
          */
-        @NonNull
-        public CallStyle setIsVideo(boolean isVideo) {
+        public @NonNull CallStyle setIsVideo(boolean isVideo) {
             mIsVideo = isVideo;
             return this;
         }
@@ -4678,8 +4642,7 @@
          * text} as a verification status of the caller.
          */
         @RequiresApi(23)
-        @NonNull
-        public CallStyle setVerificationIcon(@Nullable Icon verificationIcon) {
+        public @NonNull CallStyle setVerificationIcon(@Nullable Icon verificationIcon) {
             mVerificationIcon = verificationIcon == null ? null :
                     IconCompat.createFromIcon(verificationIcon);
             return this;
@@ -4689,8 +4652,7 @@
          * Sets an optional icon to be displayed with {@link #setVerificationText(CharSequence)
          * text} as a verification status of the caller.
          */
-        @NonNull
-        public CallStyle setVerificationIcon(@Nullable Bitmap verificationIcon) {
+        public @NonNull CallStyle setVerificationIcon(@Nullable Bitmap verificationIcon) {
             mVerificationIcon = IconCompat.createWithBitmap(verificationIcon);
             return this;
         }
@@ -4699,8 +4661,7 @@
          * Sets optional text to be displayed with an {@link #setVerificationIcon(Icon) icon}
          * as a verification status of the caller.
          */
-        @NonNull
-        public CallStyle setVerificationText(@Nullable CharSequence verificationText) {
+        public @NonNull CallStyle setVerificationText(@Nullable CharSequence verificationText) {
             mVerificationText = verificationText;
             return this;
         }
@@ -4710,8 +4671,7 @@
          * The system may change this color to ensure sufficient contrast with the background.
          * The system may choose to disregard this hint if the notification is not colorized.
          */
-        @NonNull
-        public CallStyle setAnswerButtonColorHint(@ColorInt int color) {
+        public @NonNull CallStyle setAnswerButtonColorHint(@ColorInt int color) {
             mAnswerButtonColor = color;
             return this;
         }
@@ -4722,8 +4682,7 @@
          * The system may change this color to ensure sufficient contrast with the background.
          * The system may choose to disregard this hint if the notification is not colorized.
          */
-        @NonNull
-        public CallStyle setDeclineButtonColorHint(@ColorInt int color) {
+        public @NonNull CallStyle setDeclineButtonColorHint(@ColorInt int color) {
             mDeclineButtonColor = color;
             return this;
         }
@@ -4818,8 +4777,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -4915,8 +4873,7 @@
          * Provides the default text for a CallStyle notification. Corresponds to Notification
          * .CallStyle
          */
-        @Nullable
-        private String getDefaultText() {
+        private @Nullable String getDefaultText() {
             switch (mCallType) {
                 case CALL_TYPE_INCOMING:
                     return mBuilder.mContext.getResources().getString(
@@ -4931,9 +4888,8 @@
             return null;
         }
 
-        @NonNull
         @RequiresApi(20)
-        private Action makeNegativeAction() {
+        private @NonNull Action makeNegativeAction() {
             int icon = R.drawable.ic_call_decline_low;
             if (Build.VERSION.SDK_INT >= 21) {
                 icon = R.drawable.ic_call_decline;
@@ -4951,9 +4907,8 @@
             }
         }
 
-        @Nullable
         @RequiresApi(20)
-        private Action makeAnswerAction() {
+        private @Nullable Action makeAnswerAction() {
             int videoIcon = R.drawable.ic_call_answer_video_low;
             int icon = R.drawable.ic_call_answer_low;
             if (Build.VERSION.SDK_INT >= 21) {
@@ -4969,10 +4924,9 @@
                     mAnswerIntent);
         }
 
-        @NonNull
         @RequiresApi(20)
-        private Action makeAction(int icon, int title, Integer colorInt, int defaultColorRes,
-                PendingIntent intent) {
+        private @NonNull Action makeAction(int icon, int title, Integer colorInt,
+                int defaultColorRes, PendingIntent intent) {
             if (colorInt == null) {
                 colorInt = ContextCompat.getColor(mBuilder.mContext, defaultColorRes);
             }
@@ -5001,9 +4955,8 @@
          *
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @NonNull
         @RequiresApi(20)
-        public ArrayList<Action> getActionsListWithSystemActions() {
+        public @NonNull ArrayList<Action> getActionsListWithSystemActions() {
             // Define the system actions we expect to see.
             final Action firstAction = makeNegativeAction();
             final Action lastAction = makeAnswerAction();
@@ -5173,17 +5126,17 @@
             private Api31Impl() {
             }
 
-            static Notification.CallStyle forIncomingCall(@NonNull android.app.Person person,
+            static Notification.CallStyle forIncomingCall(android.app.@NonNull Person person,
                     @NonNull PendingIntent declineIntent, @NonNull PendingIntent answerIntent) {
                 return Notification.CallStyle.forIncomingCall(person, declineIntent, answerIntent);
             }
 
-            static Notification.CallStyle forOngoingCall(@NonNull android.app.Person person,
+            static Notification.CallStyle forOngoingCall(android.app.@NonNull Person person,
                     @NonNull PendingIntent hangUpIntent) {
                 return Notification.CallStyle.forOngoingCall(person, hangUpIntent);
             }
 
-            static Notification.CallStyle forScreeningCall(@NonNull android.app.Person person,
+            static Notification.CallStyle forScreeningCall(android.app.@NonNull Person person,
                     @NonNull PendingIntent hangUpIntent, @NonNull PendingIntent answerIntent) {
                 return Notification.CallStyle.forScreeningCall(person, hangUpIntent, answerIntent);
             }
@@ -5290,8 +5243,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -5376,8 +5328,7 @@
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @Override
-        @NonNull
-        protected String getClassName() {
+        protected @NonNull String getClassName() {
             return TEMPLATE_CLASS_NAME;
         }
 
@@ -5477,10 +5428,9 @@
          * {@link Notification#contentView} when no big content view has set, or
          * {@link Notification#headsUpContentView} when set. Otherwise, returns the empty list.
          */
-        @NonNull
         @SuppressWarnings("MixedMutabilityReturnType")
         @RequiresApi(24)
-        public static List<CharSequence> getTextsFromContentView(@NonNull Context context,
+        public static @NonNull List<CharSequence> getTextsFromContentView(@NonNull Context context,
                 @NonNull Notification notification) {
             final String styleClassName = notification.extras.getString(EXTRA_TEMPLATE);
             if (!Notification.DecoratedCustomViewStyle.class.getName().equals(styleClassName)) {
@@ -5689,7 +5639,7 @@
         static final String EXTRA_SEMANTIC_ACTION = "android.support.action.semanticAction";
 
         final Bundle mExtras;
-        @Nullable private IconCompat mIcon;
+        private @Nullable IconCompat mIcon;
         private final RemoteInput[] mRemoteInputs;
 
         /**
@@ -5744,10 +5694,10 @@
         }
 
         Action(int icon, @Nullable CharSequence title, @Nullable PendingIntent intent,
-                @Nullable Bundle extras,
-                @Nullable RemoteInput[] remoteInputs, @Nullable RemoteInput[] dataOnlyRemoteInputs,
-                boolean allowGeneratedReplies, @SemanticAction int semanticAction,
-                boolean showsUserInterface, boolean isContextual, boolean requireAuth) {
+                @Nullable Bundle extras, RemoteInput @Nullable [] remoteInputs,
+                RemoteInput @Nullable [] dataOnlyRemoteInputs, boolean allowGeneratedReplies,
+                @SemanticAction int semanticAction, boolean showsUserInterface,
+                boolean isContextual, boolean requireAuth) {
             this(icon == 0 ? null : IconCompat.createWithResource(null, "", icon), title,
                     intent, extras, remoteInputs, dataOnlyRemoteInputs, allowGeneratedReplies,
                     semanticAction, showsUserInterface, isContextual, requireAuth);
@@ -5757,9 +5707,10 @@
         @SuppressWarnings("deprecation")
         Action(@Nullable IconCompat icon, @Nullable CharSequence title,
                 @Nullable PendingIntent intent, @Nullable Bundle extras,
-                @Nullable RemoteInput[] remoteInputs, @Nullable RemoteInput[] dataOnlyRemoteInputs,
-                boolean allowGeneratedReplies, @SemanticAction int semanticAction,
-                boolean showsUserInterface, boolean isContextual, boolean requireAuth) {
+                RemoteInput @Nullable [] remoteInputs,
+                RemoteInput @Nullable [] dataOnlyRemoteInputs, boolean allowGeneratedReplies,
+                @SemanticAction int semanticAction, boolean showsUserInterface,
+                boolean isContextual, boolean requireAuth) {
             this.mIcon = icon;
             if (icon != null && icon.getType() == IconCompat.TYPE_RESOURCE) {
                 this.icon = icon.getResId();
@@ -5835,7 +5786,7 @@
          * May return null if no remote inputs were added. Only returns inputs which accept
          * a text input. For inputs which only accept data use {@link #getDataOnlyRemoteInputs}.
          */
-        public @Nullable RemoteInput[] getRemoteInputs() {
+        public RemoteInput @Nullable [] getRemoteInputs() {
             return mRemoteInputs;
         }
 
@@ -5869,7 +5820,7 @@
          * <p>This method exists so that legacy RemoteInput collectors that pre-date the addition
          * of non-textual RemoteInputs do not access these remote inputs.
          */
-        public @Nullable RemoteInput[] getDataOnlyRemoteInputs() {
+        public RemoteInput @Nullable [] getDataOnlyRemoteInputs() {
             return mDataOnlyRemoteInputs;
         }
 
@@ -5901,8 +5852,7 @@
              *
              */
             @RestrictTo(LIBRARY_GROUP_PREFIX)
-            @NonNull
-            public static Builder fromAndroidAction(@NonNull Notification.Action action) {
+            public static @NonNull Builder fromAndroidAction(Notification.@NonNull Action action) {
                 final Builder builder;
                 if (Build.VERSION.SDK_INT >= 23 && Api23Impl.getIcon(action) != null) {
                     IconCompat iconCompat = IconCompat.createFromIconOrNullIfZeroResId(
@@ -5986,7 +5936,7 @@
 
             private Builder(@Nullable IconCompat icon, @Nullable CharSequence title,
                     @Nullable PendingIntent intent, @NonNull Bundle extras,
-                    @Nullable RemoteInput[] remoteInputs, boolean allowGeneratedReplies,
+                    RemoteInput @Nullable [] remoteInputs, boolean allowGeneratedReplies,
                     @SemanticAction int semanticAction, boolean showsUserInterface,
                     boolean isContextual, boolean authRequired) {
                 mIcon = icon;
@@ -6088,8 +6038,7 @@
              * If this is false and the device is locked, the OS will decide whether authentication
              * should be required.
              */
-            @NonNull
-            public Builder setAuthenticationRequired(boolean authenticationRequired) {
+            public @NonNull Builder setAuthenticationRequired(boolean authenticationRequired) {
                 mAuthenticationRequired = authenticationRequired;
                 return this;
             }
@@ -6329,7 +6278,7 @@
              * method of {@link NotificationCompat.Action.Builder}.
              */
             @Override
-            public @NonNull Action.Builder extend(@NonNull Action.Builder builder) {
+            public Action.@NonNull Builder extend(Action.@NonNull Builder builder) {
                 Bundle wearableBundle = new Bundle();
 
                 if (mFlags != DEFAULT_FLAGS) {
@@ -6440,8 +6389,7 @@
              * @deprecated This method has no effect starting with Wear 2.0.
              */
             @Deprecated
-            @Nullable
-            public CharSequence getConfirmLabel() {
+            public @Nullable CharSequence getConfirmLabel() {
                 return mConfirmLabel;
             }
 
@@ -6802,8 +6750,8 @@
          */
         @SuppressWarnings("deprecation")
         @Override
-        @NonNull
-        public NotificationCompat.Builder extend(@NonNull NotificationCompat.Builder builder) {
+        public NotificationCompat.@NonNull Builder extend(
+                NotificationCompat.@NonNull Builder builder) {
             Bundle wearableBundle = new Bundle();
 
             if (!mActions.isEmpty()) {
@@ -6909,8 +6857,7 @@
         }
 
         @Override
-        @NonNull
-        public WearableExtender clone() {
+        public @NonNull WearableExtender clone() {
             WearableExtender that = new WearableExtender();
             that.mActions = new ArrayList<>(this.mActions);
             that.mFlags = this.mFlags;
@@ -7381,8 +7328,7 @@
          * @deprecated This method has no effect starting with Wear 2.0.
          */
         @Deprecated
-        @NonNull
-        public WearableExtender setHintAvoidBackgroundClipping(
+        public @NonNull WearableExtender setHintAvoidBackgroundClipping(
                 boolean hintAvoidBackgroundClipping) {
             setFlag(FLAG_HINT_AVOID_BACKGROUND_CLIPPING, hintAvoidBackgroundClipping);
             return this;
@@ -7787,8 +7733,8 @@
          * method of {@link NotificationCompat.Builder}.
          */
         @Override
-        @NonNull
-        public NotificationCompat.Builder extend(@NonNull NotificationCompat.Builder builder) {
+        public NotificationCompat.@NonNull Builder extend(
+                NotificationCompat.@NonNull Builder builder) {
             if (Build.VERSION.SDK_INT < 21) {
                 return builder;
             }
@@ -7868,8 +7814,8 @@
          * instead.
          */
         @Deprecated
-        @NonNull
-        public CarExtender setUnreadConversation(@Nullable UnreadConversation unreadConversation) {
+        public @NonNull CarExtender setUnreadConversation(
+                @Nullable UnreadConversation unreadConversation) {
             mUnreadConversation = unreadConversation;
             return this;
         }
@@ -7901,10 +7847,10 @@
             private final String[] mParticipants;
             private final long mLatestTimestamp;
 
-            UnreadConversation(@Nullable String[] messages, @Nullable RemoteInput remoteInput,
+            UnreadConversation(String @Nullable [] messages, @Nullable RemoteInput remoteInput,
                     @Nullable PendingIntent replyPendingIntent,
                     @Nullable PendingIntent readPendingIntent,
-                    @Nullable String[] participants, long latestTimestamp) {
+                    String @Nullable [] participants, long latestTimestamp) {
                 mMessages = messages;
                 mRemoteInput = remoteInput;
                 mReadPendingIntent = readPendingIntent;
@@ -7916,7 +7862,7 @@
             /**
              * Gets the list of messages conveyed by this notification.
              */
-            public @Nullable String[] getMessages() {
+            public String @Nullable [] getMessages() {
                 return mMessages;
             }
 
@@ -7947,7 +7893,7 @@
             /**
              * Gets the participants in the conversation.
              */
-            public @Nullable String[] getParticipants() {
+            public String @Nullable [] getParticipants() {
                 return mParticipants;
             }
 
@@ -8231,8 +8177,8 @@
          * method of {@link NotificationCompat.Builder}.
          */
         @Override
-        @NonNull
-        public NotificationCompat.Builder extend(@NonNull NotificationCompat.Builder builder) {
+        public NotificationCompat.@NonNull Builder extend(
+                NotificationCompat.@NonNull Builder builder) {
             // TvExtender was introduced in API level 26; note that before API level 26, the extras
             // added by TvExtender are not expected to be used; thus, we avoid setting them to save
             // memory.
@@ -8418,8 +8364,7 @@
          * null if this bubble is created via {@link Builder#Builder(String)}.
          */
         @SuppressLint("InvalidNullConversion")
-        @Nullable
-        public PendingIntent getIntent() {
+        public @Nullable PendingIntent getIntent() {
             return mPendingIntent;
         }
 
@@ -8428,16 +8373,14 @@
          * {@link Builder#Builder(String)} or null if created via
          * {@link Builder#Builder(PendingIntent, IconCompat)}.
          */
-        @Nullable
-        public String getShortcutId() {
+        public @Nullable String getShortcutId() {
             return mShortcutId;
         }
 
         /**
          * @return the pending intent to send when the bubble is dismissed by a user, if one exists.
          */
-        @Nullable
-        public PendingIntent getDeleteIntent() {
+        public @Nullable PendingIntent getDeleteIntent() {
             return mDeleteIntent;
         }
 
@@ -8446,8 +8389,7 @@
          * if the bubble is created via {@link Builder#Builder(String)}.
          */
         @SuppressLint("InvalidNullConversion")
-        @Nullable
-        public IconCompat getIcon() {
+        public @Nullable IconCompat getIcon() {
             return mIcon;
         }
 
@@ -8502,7 +8444,7 @@
          * @return a {@link Notification.BubbleMetadata} containing the same data if compatMetadata
          * is non-null, otherwise null.
          */
-        public static @Nullable android.app.Notification.BubbleMetadata toPlatform(
+        public static android.app.Notification.@Nullable BubbleMetadata toPlatform(
                 @Nullable BubbleMetadata compatMetadata) {
             if (compatMetadata == null) {
                 return null;
@@ -8524,7 +8466,7 @@
          * platformMetadata is non-null, otherwise null.
          */
         public static @Nullable BubbleMetadata fromPlatform(
-                @Nullable android.app.Notification.BubbleMetadata platformMetadata) {
+                android.app.Notification.@Nullable BubbleMetadata platformMetadata) {
             if (platformMetadata == null) {
                 return null;
             }
@@ -8620,8 +8562,7 @@
              * @throws IllegalStateException if this builder was created via
              *                               {@link #Builder(String)}.
              */
-            @NonNull
-            public BubbleMetadata.Builder setIntent(@NonNull PendingIntent intent) {
+            public BubbleMetadata.@NonNull Builder setIntent(@NonNull PendingIntent intent) {
                 if (mShortcutId != null) {
                     throw new IllegalStateException("Created as a shortcut bubble, cannot set a "
                             + "PendingIntent. Consider using "
@@ -8648,8 +8589,7 @@
              * @throws IllegalStateException if this builder was created via
              *                               {@link #Builder(String)}.
              */
-            @NonNull
-            public BubbleMetadata.Builder setIcon(@NonNull IconCompat icon) {
+            public BubbleMetadata.@NonNull Builder setIcon(@NonNull IconCompat icon) {
                 if (mShortcutId != null) {
                     throw new IllegalStateException("Created as a shortcut bubble, cannot set an "
                             + "Icon. Consider using "
@@ -8671,8 +8611,8 @@
              * previous value set will be cleared after calling this method, and this value will
              * be used instead.
              */
-            @NonNull
-            public BubbleMetadata.Builder setDesiredHeight(@Dimension(unit = DP) int height) {
+            public BubbleMetadata.@NonNull Builder setDesiredHeight(
+                    @Dimension(unit = DP) int height) {
                 mDesiredHeight = Math.max(height, 0);
                 mDesiredHeightResId = 0;
                 return this;
@@ -8687,8 +8627,8 @@
              * previous value set will be cleared after calling this method, and this value will
              * be used instead.
              */
-            @NonNull
-            public BubbleMetadata.Builder setDesiredHeightResId(@DimenRes int heightResId) {
+            public BubbleMetadata.@NonNull Builder setDesiredHeightResId(
+                    @DimenRes int heightResId) {
                 mDesiredHeightResId = heightResId;
                 mDesiredHeight = 0;
                 return this;
@@ -8705,8 +8645,7 @@
              * <p>Generally this flag should only be set if the user has performed an action to
              * request or create a bubble.</p>
              */
-            @NonNull
-            public BubbleMetadata.Builder setAutoExpandBubble(boolean shouldExpand) {
+            public BubbleMetadata.@NonNull Builder setAutoExpandBubble(boolean shouldExpand) {
                 setFlag(FLAG_AUTO_EXPAND_BUBBLE, shouldExpand);
                 return this;
             }
@@ -8722,8 +8661,7 @@
              * request or create a bubble, or if the user has seen the content in the notification
              * and the notification is no longer relevant.</p>
              */
-            @NonNull
-            public BubbleMetadata.Builder setSuppressNotification(
+            public BubbleMetadata.@NonNull Builder setSuppressNotification(
                     boolean shouldSuppressNotif) {
                 setFlag(FLAG_SUPPRESS_NOTIFICATION, shouldSuppressNotif);
                 return this;
@@ -8732,8 +8670,8 @@
             /**
              * Sets an optional intent to send when this bubble is explicitly removed by the user.
              */
-            @NonNull
-            public BubbleMetadata.Builder setDeleteIntent(@Nullable PendingIntent deleteIntent) {
+            public BubbleMetadata.@NonNull Builder setDeleteIntent(
+                    @Nullable PendingIntent deleteIntent) {
                 mDeleteIntent = deleteIntent;
                 return this;
             }
@@ -8743,8 +8681,7 @@
              * <p>Will throw {@link NullPointerException} if required fields have not been set
              * on this builder.</p>
              */
-            @NonNull
-            public BubbleMetadata build() {
+            public @NonNull BubbleMetadata build() {
                 if (mShortcutId == null && mPendingIntent == null) {
                     throw new NullPointerException(
                             "Must supply pending intent or shortcut to bubble");
@@ -8759,8 +8696,7 @@
                 return data;
             }
 
-            @NonNull
-            private BubbleMetadata.Builder setFlag(int mask, boolean value) {
+            private BubbleMetadata.@NonNull Builder setFlag(int mask, boolean value) {
                 if (value) {
                     mFlags |= mask;
                 } else {
@@ -8785,7 +8721,7 @@
              * compatMetadata is non-null, otherwise null.
              */
             @RequiresApi(29)
-            @Nullable static android.app.Notification.BubbleMetadata toPlatform(
+            static android.app.Notification.@Nullable BubbleMetadata toPlatform(
                     @Nullable BubbleMetadata compatMetadata) {
                 if (compatMetadata == null) {
                     return null;
@@ -8824,8 +8760,8 @@
              * platformMetadata is non-null, otherwise null.
              */
             @RequiresApi(29)
-            @Nullable static BubbleMetadata fromPlatform(
-                    @Nullable android.app.Notification.BubbleMetadata platformMetadata) {
+            static @Nullable BubbleMetadata fromPlatform(
+                    android.app.Notification.@Nullable BubbleMetadata platformMetadata) {
                 if (platformMetadata == null) {
                     return null;
                 }
@@ -8868,7 +8804,7 @@
              * compatMetadata is non-null, otherwise null.
              */
             @RequiresApi(30)
-            @Nullable static android.app.Notification.BubbleMetadata toPlatform(
+            static android.app.Notification.@Nullable BubbleMetadata toPlatform(
                     @Nullable BubbleMetadata compatMetadata) {
                 if (compatMetadata == null) {
                     return null;
@@ -8909,8 +8845,8 @@
              * platformMetadata is non-null, otherwise null.
              */
             @RequiresApi(30)
-            @Nullable static BubbleMetadata fromPlatform(
-                    @Nullable android.app.Notification.BubbleMetadata platformMetadata) {
+            static @Nullable BubbleMetadata fromPlatform(
+                    android.app.Notification.@Nullable BubbleMetadata platformMetadata) {
                 if (platformMetadata == null) {
                     return null;
                 }
@@ -8947,7 +8883,7 @@
      * to do an array copy.
      */
     @SuppressWarnings("deprecation")
-    static @NonNull Notification[] getNotificationArrayFromBundle(@NonNull Bundle bundle,
+    static Notification @NonNull [] getNotificationArrayFromBundle(@NonNull Bundle bundle,
             @NonNull String key) {
         Parcelable[] array = bundle.getParcelableArray(key);
         if (array instanceof Notification[] || array == null) {
@@ -8969,8 +8905,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "notification.extras")
-    @Nullable
-    public static Bundle getExtras(@NonNull Notification notification) {
+    public static @Nullable Bundle getExtras(@NonNull Notification notification) {
         return notification.extras;
     }
 
@@ -9023,7 +8958,7 @@
 
     @SuppressWarnings("deprecation")
     @RequiresApi(20)
-    static @NonNull Action getActionCompatFromAction(@NonNull Notification.Action action) {
+    static @NonNull Action getActionCompatFromAction(Notification.@NonNull Action action) {
         final RemoteInput[] remoteInputs;
         final android.app.RemoteInput[] srcArray = Api20Impl.getRemoteInputs(action);
         if (srcArray == null) {
@@ -9359,8 +9294,7 @@
      * {@link androidx.core.content.pm.ShortcutInfoCompat} and
      * {@link android.view.contentcapture.ContentCaptureContext}).
      */
-    @Nullable
-    public static LocusIdCompat getLocusId(@NonNull Notification notification) {
+    public static @Nullable LocusIdCompat getLocusId(@NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= 29) {
             LocusId locusId = Api29Impl.getLocusId(notification);
             return locusId == null ? null : LocusIdCompat.toLocusIdCompat(locusId);
diff --git a/core/core/src/main/java/androidx/core/app/NotificationCompatBuilder.java b/core/core/src/main/java/androidx/core/app/NotificationCompatBuilder.java
index 8dbcd23..06cb36d 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationCompatBuilder.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationCompatBuilder.java
@@ -37,12 +37,13 @@
 import android.util.SparseArray;
 import android.widget.RemoteViews;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.collection.ArraySet;
 import androidx.core.graphics.drawable.IconCompat;
 
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -291,9 +292,8 @@
         }
     }
 
-    @Nullable
-    private static List<String> combineLists(@Nullable final List<String> first,
-            @Nullable final List<String> second) {
+    private static @Nullable List<String> combineLists(final @Nullable List<String> first,
+            final @Nullable List<String> second) {
         if (first == null) {
             return second;
         }
@@ -306,8 +306,7 @@
         return new ArrayList<>(people);
     }
 
-    @Nullable
-    private static List<String> getPeople(@Nullable final List<Person> people) {
+    private static @Nullable List<String> getPeople(final @Nullable List<Person> people) {
         if (people == null) {
             return null;
         }
diff --git a/core/core/src/main/java/androidx/core/app/NotificationManagerCompat.java b/core/core/src/main/java/androidx/core/app/NotificationManagerCompat.java
index 7e005fd..54721fe 100644
--- a/core/core/src/main/java/androidx/core/app/NotificationManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/app/NotificationManagerCompat.java
@@ -50,13 +50,14 @@
 
 import androidx.annotation.GuardedBy;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.reflect.Field;
@@ -211,8 +212,7 @@
     public static final int IMPORTANCE_MAX = 5;
 
     /** Get a {@link NotificationManagerCompat} instance for a provided context. */
-    @NonNull
-    public static NotificationManagerCompat from(@NonNull Context context) {
+    public static @NonNull NotificationManagerCompat from(@NonNull Context context) {
         return new NotificationManagerCompat(context);
     }
 
@@ -351,8 +351,7 @@
      *
      * @return A list of {@link StatusBarNotification}.
      */
-    @NonNull
-    public List<StatusBarNotification> getActiveNotifications() {
+    public @NonNull List<StatusBarNotification> getActiveNotifications() {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getActiveNotifications(mNotificationManager);
         } else {
@@ -602,8 +601,7 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannel getNotificationChannel(@NonNull String channelId) {
+    public @Nullable NotificationChannel getNotificationChannel(@NonNull String channelId) {
         if (Build.VERSION.SDK_INT >= 26) {
             return Api26Impl.getNotificationChannel(mNotificationManager, channelId);
         }
@@ -615,8 +613,8 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannelCompat getNotificationChannelCompat(@NonNull String channelId) {
+    public @Nullable NotificationChannelCompat getNotificationChannelCompat(
+            @NonNull String channelId) {
         if (Build.VERSION.SDK_INT >= 26) {
             NotificationChannel channel = getNotificationChannel(channelId);
             if (channel != null) {
@@ -634,8 +632,7 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannel getNotificationChannel(@NonNull String channelId,
+    public @Nullable NotificationChannel getNotificationChannel(@NonNull String channelId,
             @NonNull String conversationId) {
         if (Build.VERSION.SDK_INT >= 30) {
             return Api30Impl.getNotificationChannel(mNotificationManager, channelId,
@@ -652,9 +649,8 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannelCompat getNotificationChannelCompat(@NonNull String channelId,
-            @NonNull String conversationId) {
+    public @Nullable NotificationChannelCompat getNotificationChannelCompat(
+            @NonNull String channelId, @NonNull String conversationId) {
         if (Build.VERSION.SDK_INT >= 26) {
             NotificationChannel channel = getNotificationChannel(channelId, conversationId);
             if (channel != null) {
@@ -669,8 +665,8 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannelGroup getNotificationChannelGroup(@NonNull String channelGroupId) {
+    public @Nullable NotificationChannelGroup getNotificationChannelGroup(
+            @NonNull String channelGroupId) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.getNotificationChannelGroup(mNotificationManager, channelGroupId);
         } else if (Build.VERSION.SDK_INT >= 26) {
@@ -690,8 +686,7 @@
      *
      * Returns {@code null} on older SDKs which don't support Notification Channels.
      */
-    @Nullable
-    public NotificationChannelGroupCompat getNotificationChannelGroupCompat(
+    public @Nullable NotificationChannelGroupCompat getNotificationChannelGroupCompat(
             @NonNull String channelGroupId) {
         if (Build.VERSION.SDK_INT >= 28) {
             NotificationChannelGroup group = getNotificationChannelGroup(channelGroupId);
@@ -711,8 +706,7 @@
      * Returns all notification channels belonging to the calling app
      * or an empty list on older SDKs which don't support Notification Channels.
      */
-    @NonNull
-    public List<NotificationChannel> getNotificationChannels() {
+    public @NonNull List<NotificationChannel> getNotificationChannels() {
         if (Build.VERSION.SDK_INT >= 26) {
             return Api26Impl.getNotificationChannels(mNotificationManager);
         }
@@ -723,9 +717,8 @@
      * Returns all notification channels belonging to the calling app
      * or an empty list on older SDKs which don't support Notification Channels.
      */
-    @NonNull
     @SuppressWarnings("MixedMutabilityReturnType")
-    public List<NotificationChannelCompat> getNotificationChannelsCompat() {
+    public @NonNull List<NotificationChannelCompat> getNotificationChannelsCompat() {
         if (Build.VERSION.SDK_INT >= 26) {
             List<NotificationChannel> channels = getNotificationChannels();
             if (!channels.isEmpty()) {
@@ -743,8 +736,7 @@
      * Returns all notification channel groups belonging to the calling app
      * or an empty list on older SDKs which don't support Notification Channels.
      */
-    @NonNull
-    public List<NotificationChannelGroup> getNotificationChannelGroups() {
+    public @NonNull List<NotificationChannelGroup> getNotificationChannelGroups() {
         if (Build.VERSION.SDK_INT >= 26) {
             return Api26Impl.getNotificationChannelGroups(mNotificationManager);
         }
@@ -755,9 +747,8 @@
      * Returns all notification channel groups belonging to the calling app
      * or an empty list on older SDKs which don't support Notification Channels.
      */
-    @NonNull
     @SuppressWarnings("MixedMutabilityReturnType")
-    public List<NotificationChannelGroupCompat> getNotificationChannelGroupsCompat() {
+    public @NonNull List<NotificationChannelGroupCompat> getNotificationChannelGroupsCompat() {
         if (Build.VERSION.SDK_INT >= 26) {
             List<NotificationChannelGroup> groups = getNotificationChannelGroups();
             if (!groups.isEmpty()) {
@@ -782,8 +773,7 @@
     /**
      * Get the set of packages that have an enabled notification listener component within them.
      */
-    @NonNull
-    public static Set<String> getEnabledListenerPackages(@NonNull Context context) {
+    public static @NonNull Set<String> getEnabledListenerPackages(@NonNull Context context) {
         final String enabledNotificationListeners = Settings.Secure.getString(
                 context.getContentResolver(),
                 SETTING_ENABLED_NOTIFICATION_LISTENERS);
@@ -1182,9 +1172,8 @@
             service.notify(packageName, id, tag, notif);
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             StringBuilder sb = new StringBuilder("NotifyTask[");
             sb.append("packageName:").append(packageName);
             sb.append(", id:").append(id);
@@ -1223,9 +1212,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             StringBuilder sb = new StringBuilder("CancelTask[");
             sb.append("packageName:").append(packageName);
             sb.append(", id:").append(id);
diff --git a/core/core/src/main/java/androidx/core/app/PendingIntentCompat.java b/core/core/src/main/java/androidx/core/app/PendingIntentCompat.java
index 0a05c07..7c88576 100644
--- a/core/core/src/main/java/androidx/core/app/PendingIntentCompat.java
+++ b/core/core/src/main/java/androidx/core/app/PendingIntentCompat.java
@@ -1,395 +1,394 @@
-/*

- * Copyright (C) 2022 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 androidx.core.app;

-

-import static android.app.PendingIntent.FLAG_IMMUTABLE;

-import static android.app.PendingIntent.FLAG_MUTABLE;

-

-import android.annotation.SuppressLint;

-import android.app.PendingIntent;

-import android.content.Context;

-import android.content.Intent;

-import android.os.Build.VERSION;

-import android.os.Build.VERSION_CODES;

-import android.os.Bundle;

-import android.os.Handler;

-

-import androidx.annotation.IntDef;

-import androidx.annotation.NonNull;

-import androidx.annotation.Nullable;

-import androidx.annotation.RequiresApi;

-import androidx.annotation.RestrictTo;

-

-import java.io.Closeable;

-import java.lang.annotation.Retention;

-import java.lang.annotation.RetentionPolicy;

-import java.util.concurrent.CountDownLatch;

-

-/** Helper for accessing features in {@link PendingIntent}. */

-public final class PendingIntentCompat {

-

-    @IntDef(

-            flag = true,

-            value = {

-                PendingIntent.FLAG_ONE_SHOT,

-                PendingIntent.FLAG_NO_CREATE,

-                PendingIntent.FLAG_CANCEL_CURRENT,

-                PendingIntent.FLAG_UPDATE_CURRENT,

-                Intent.FILL_IN_ACTION,

-                Intent.FILL_IN_DATA,

-                Intent.FILL_IN_CATEGORIES,

-                Intent.FILL_IN_COMPONENT,

-                Intent.FILL_IN_PACKAGE,

-                Intent.FILL_IN_SOURCE_BOUNDS,

-                Intent.FILL_IN_SELECTOR,

-                Intent.FILL_IN_CLIP_DATA

-            })

-    @Retention(RetentionPolicy.SOURCE)

-    @RestrictTo(RestrictTo.Scope.LIBRARY)

-    public @interface Flags {}

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary. See {@link

-     * PendingIntent#getActivities(Context, int, Intent[], int, Bundle)}.

-     */

-    public static @NonNull PendingIntent getActivities(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull @SuppressLint("ArrayReturn") Intent[] intents,

-            @Flags int flags,

-            @Nullable Bundle options,

-            boolean isMutable) {

-        return PendingIntent.getActivities(context, requestCode, intents,

-                addMutabilityFlags(isMutable, flags), options);

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary. See {@link

-     * PendingIntent#getActivities(Context, int, Intent[], int, Bundle)}.

-     */

-    public static @NonNull PendingIntent getActivities(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull @SuppressLint("ArrayReturn") Intent[] intents,

-            @Flags int flags,

-            boolean isMutable) {

-        return PendingIntent.getActivities(

-                context, requestCode, intents, addMutabilityFlags(isMutable, flags));

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary.

-     *

-     * @return Returns an existing or new PendingIntent matching the given parameters. May return

-     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.

-     * @see PendingIntent#getActivity(Context, int, Intent, int)

-     */

-    public static @Nullable PendingIntent getActivity(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull Intent intent,

-            @Flags int flags,

-            boolean isMutable) {

-        return PendingIntent.getActivity(

-                context, requestCode, intent, addMutabilityFlags(isMutable, flags));

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary.

-     *

-     * @return Returns an existing or new PendingIntent matching the given parameters. May return

-     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.

-     * @see PendingIntent#getActivity(Context, int, Intent, int, Bundle)

-     */

-    public static @Nullable PendingIntent getActivity(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull Intent intent,

-            @Flags int flags,

-            @Nullable Bundle options,

-            boolean isMutable) {

-        return PendingIntent.getActivity(context, requestCode, intent,

-                addMutabilityFlags(isMutable, flags), options);

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary.

-     *

-     * @return Returns an existing or new PendingIntent matching the given parameters. May return

-     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.

-     * @see PendingIntent#getBroadcast(Context, int, Intent, int)

-     */

-    public static @Nullable PendingIntent getBroadcast(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull Intent intent,

-            @Flags int flags,

-            boolean isMutable) {

-        return PendingIntent.getBroadcast(

-                context, requestCode, intent, addMutabilityFlags(isMutable, flags));

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary. See {@link

-     * PendingIntent#getForegroundService(Context, int, Intent, int)} .

-     */

-    @RequiresApi(26)

-    public static @NonNull PendingIntent getForegroundService(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull Intent intent,

-            @Flags int flags,

-            boolean isMutable) {

-        return Api26Impl.getForegroundService(

-                context, requestCode, intent, addMutabilityFlags(isMutable, flags));

-    }

-

-    /**

-     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform

-     * versions. The caller provides the flag as combination of all the other values except

-     * mutability flag. This method combines mutability flag when necessary.

-     *

-     * @return Returns an existing or new PendingIntent matching the given parameters. May return

-     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.

-     * @see PendingIntent#getService(Context, int, Intent, int)

-     */

-    public static @Nullable PendingIntent getService(

-            @NonNull Context context,

-            int requestCode,

-            @NonNull Intent intent,

-            @Flags int flags,

-            boolean isMutable) {

-        return PendingIntent.getService(

-                context, requestCode, intent, addMutabilityFlags(isMutable, flags));

-    }

-

-    /**

-     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks

-     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was

-     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a

-     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and

-     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()

-     * completed successfully.

-     *

-     * <p>See {@link PendingIntent#send(int, PendingIntent.OnFinished, Handler)}.

-     */

-    @SuppressLint("LambdaLast")  // compat shim so arguments should be in the same order

-    public static void send(

-            @NonNull PendingIntent pendingIntent,

-            int code,

-            @Nullable PendingIntent.OnFinished onFinished,

-            @Nullable Handler handler) throws PendingIntent.CanceledException {

-        try (GatedCallback gatedCallback = new GatedCallback(onFinished)) {

-            pendingIntent.send(code, gatedCallback.getCallback(), handler);

-            gatedCallback.complete();

-        }

-    }

-

-    /**

-     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks

-     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was

-     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a

-     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and

-     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()

-     * completed successfully.

-     *

-     * <p>See {@link PendingIntent#send(Context, int, Intent, PendingIntent.OnFinished, Handler)}.

-     */

-    @SuppressLint("LambdaLast")  // compat shim so arguments must be in the same order

-    public static void send(

-            @NonNull PendingIntent pendingIntent,

-            // compat shim so arguments must be in the same order

-            @SuppressLint("ContextFirst") @NonNull Context context,

-            int code,

-            @NonNull Intent intent,

-            @Nullable PendingIntent.OnFinished onFinished,

-            @Nullable Handler handler) throws PendingIntent.CanceledException {

-        send(pendingIntent, context, code, intent, onFinished, handler, null, null);

-    }

-

-    /**

-     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks

-     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was

-     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a

-     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and

-     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()

-     * completed successfully.

-     *

-     * <p>See {@link

-     * PendingIntent#send(Context, int, Intent, PendingIntent.OnFinished, Handler, String, Bundle)}

-     */

-    @SuppressLint("LambdaLast")  // compat shim so arguments must be in the same order

-    public static void send(

-            @NonNull PendingIntent pendingIntent,

-            // compat shim so arguments must be in the same order

-            @SuppressLint("ContextFirst") @NonNull Context context,

-            int code,

-            @NonNull Intent intent,

-            @Nullable PendingIntent.OnFinished onFinished,

-            @Nullable Handler handler,

-            @Nullable String requiredPermissions,

-            @Nullable Bundle options) throws PendingIntent.CanceledException {

-        try (GatedCallback gatedCallback = new GatedCallback(onFinished)) {

-            if (VERSION.SDK_INT >= VERSION_CODES.M) {

-                Api23Impl.send(

-                        pendingIntent,

-                        context,

-                        code,

-                        intent,

-                        onFinished,

-                        handler,

-                        requiredPermissions,

-                        options);

-            } else {

-                pendingIntent.send(context, code, intent, gatedCallback.getCallback(), handler,

-                        requiredPermissions);

-            }

-            gatedCallback.complete();

-        }

-    }

-

-    static int addMutabilityFlags(boolean isMutable, int flags) {

-        if (isMutable) {

-            if (VERSION.SDK_INT >= 31) {

-                flags |= FLAG_MUTABLE;

-            }

-        } else {

-            if (VERSION.SDK_INT >= 23) {

-                flags |= FLAG_IMMUTABLE;

-            }

-        }

-

-        return flags;

-    }

-

-    private PendingIntentCompat() {}

-

-    @RequiresApi(23)

-    private static class Api23Impl {

-        private Api23Impl() {}

-

-        public static void send(

-                @NonNull PendingIntent pendingIntent,

-                @NonNull Context context,

-                int code,

-                @NonNull Intent intent,

-                @Nullable PendingIntent.OnFinished onFinished,

-                @Nullable Handler handler,

-                @Nullable String requiredPermission,

-                @Nullable Bundle options) throws PendingIntent.CanceledException {

-            pendingIntent.send(

-                    context,

-                    code,

-                    intent,

-                    onFinished,

-                    handler,

-                    requiredPermission,

-                    options);

-        }

-    }

-

-    @RequiresApi(26)

-    private static class Api26Impl {

-        private Api26Impl() {}

-

-        public static PendingIntent getForegroundService(

-                Context context, int requestCode, Intent intent, int flags) {

-            return PendingIntent.getForegroundService(context, requestCode, intent, flags);

-        }

-    }

-

-    // see b/201299281 for more info and context

-    private static class GatedCallback implements Closeable {

-

-        private final CountDownLatch mComplete = new CountDownLatch(1);

-

-        @Nullable

-        private PendingIntent.OnFinished mCallback;

-        private boolean mSuccess;

-

-        GatedCallback(@Nullable PendingIntent.OnFinished callback) {

-            this.mCallback = callback;

-            mSuccess = false;

-        }

-

-        @Nullable

-        public PendingIntent.OnFinished getCallback() {

-            if (mCallback == null) {

-                return null;

-            } else {

-                return this::onSendFinished;

-            }

-        }

-

-        public void complete() {

-            mSuccess = true;

-        }

-

-        @Override

-        public void close() {

-            if (!mSuccess) {

-                mCallback = null;

-            }

-            mComplete.countDown();

-        }

-

-        private void onSendFinished(

-                PendingIntent pendingIntent,

-                Intent intent,

-                int resultCode,

-                String resultData,

-                Bundle resultExtras) {

-            boolean interrupted = false;

-            try {

-                while (true) {

-                    try {

-                        mComplete.await();

-                        break;

-                    } catch (InterruptedException e) {

-                        interrupted = true;

-                    }

-                }

-            } finally {

-                if (interrupted) {

-                    Thread.currentThread().interrupt();

-                }

-            }

-

-            if (mCallback != null) {

-                mCallback.onSendFinished(

-                        pendingIntent,

-                        intent,

-                        resultCode,

-                        resultData,

-                        resultExtras);

-                mCallback = null;

-            }

-        }

-    }

-}

+/*
+ * Copyright (C) 2022 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 androidx.core.app;
+
+import static android.app.PendingIntent.FLAG_IMMUTABLE;
+import static android.app.PendingIntent.FLAG_MUTABLE;
+
+import android.annotation.SuppressLint;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.io.Closeable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.concurrent.CountDownLatch;
+
+/** Helper for accessing features in {@link PendingIntent}. */
+public final class PendingIntentCompat {
+
+    @IntDef(
+            flag = true,
+            value = {
+                PendingIntent.FLAG_ONE_SHOT,
+                PendingIntent.FLAG_NO_CREATE,
+                PendingIntent.FLAG_CANCEL_CURRENT,
+                PendingIntent.FLAG_UPDATE_CURRENT,
+                Intent.FILL_IN_ACTION,
+                Intent.FILL_IN_DATA,
+                Intent.FILL_IN_CATEGORIES,
+                Intent.FILL_IN_COMPONENT,
+                Intent.FILL_IN_PACKAGE,
+                Intent.FILL_IN_SOURCE_BOUNDS,
+                Intent.FILL_IN_SELECTOR,
+                Intent.FILL_IN_CLIP_DATA
+            })
+    @Retention(RetentionPolicy.SOURCE)
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    public @interface Flags {}
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary. See {@link
+     * PendingIntent#getActivities(Context, int, Intent[], int, Bundle)}.
+     */
+    public static @NonNull PendingIntent getActivities(
+            @NonNull Context context,
+            int requestCode,
+            @SuppressLint("ArrayReturn") Intent @NonNull [] intents,
+            @Flags int flags,
+            @Nullable Bundle options,
+            boolean isMutable) {
+        return PendingIntent.getActivities(context, requestCode, intents,
+                addMutabilityFlags(isMutable, flags), options);
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary. See {@link
+     * PendingIntent#getActivities(Context, int, Intent[], int, Bundle)}.
+     */
+    public static @NonNull PendingIntent getActivities(
+            @NonNull Context context,
+            int requestCode,
+            @SuppressLint("ArrayReturn") Intent @NonNull [] intents,
+            @Flags int flags,
+            boolean isMutable) {
+        return PendingIntent.getActivities(
+                context, requestCode, intents, addMutabilityFlags(isMutable, flags));
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary.
+     *
+     * @return Returns an existing or new PendingIntent matching the given parameters. May return
+     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
+     * @see PendingIntent#getActivity(Context, int, Intent, int)
+     */
+    public static @Nullable PendingIntent getActivity(
+            @NonNull Context context,
+            int requestCode,
+            @NonNull Intent intent,
+            @Flags int flags,
+            boolean isMutable) {
+        return PendingIntent.getActivity(
+                context, requestCode, intent, addMutabilityFlags(isMutable, flags));
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary.
+     *
+     * @return Returns an existing or new PendingIntent matching the given parameters. May return
+     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
+     * @see PendingIntent#getActivity(Context, int, Intent, int, Bundle)
+     */
+    public static @Nullable PendingIntent getActivity(
+            @NonNull Context context,
+            int requestCode,
+            @NonNull Intent intent,
+            @Flags int flags,
+            @Nullable Bundle options,
+            boolean isMutable) {
+        return PendingIntent.getActivity(context, requestCode, intent,
+                addMutabilityFlags(isMutable, flags), options);
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary.
+     *
+     * @return Returns an existing or new PendingIntent matching the given parameters. May return
+     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
+     * @see PendingIntent#getBroadcast(Context, int, Intent, int)
+     */
+    public static @Nullable PendingIntent getBroadcast(
+            @NonNull Context context,
+            int requestCode,
+            @NonNull Intent intent,
+            @Flags int flags,
+            boolean isMutable) {
+        return PendingIntent.getBroadcast(
+                context, requestCode, intent, addMutabilityFlags(isMutable, flags));
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary. See {@link
+     * PendingIntent#getForegroundService(Context, int, Intent, int)} .
+     */
+    @RequiresApi(26)
+    public static @NonNull PendingIntent getForegroundService(
+            @NonNull Context context,
+            int requestCode,
+            @NonNull Intent intent,
+            @Flags int flags,
+            boolean isMutable) {
+        return Api26Impl.getForegroundService(
+                context, requestCode, intent, addMutabilityFlags(isMutable, flags));
+    }
+
+    /**
+     * Retrieves a {@link PendingIntent} with mandatory mutability flag set on supported platform
+     * versions. The caller provides the flag as combination of all the other values except
+     * mutability flag. This method combines mutability flag when necessary.
+     *
+     * @return Returns an existing or new PendingIntent matching the given parameters. May return
+     *         {@code null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
+     * @see PendingIntent#getService(Context, int, Intent, int)
+     */
+    public static @Nullable PendingIntent getService(
+            @NonNull Context context,
+            int requestCode,
+            @NonNull Intent intent,
+            @Flags int flags,
+            boolean isMutable) {
+        return PendingIntent.getService(
+                context, requestCode, intent, addMutabilityFlags(isMutable, flags));
+    }
+
+    /**
+     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks
+     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was
+     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a
+     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and
+     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()
+     * completed successfully.
+     *
+     * <p>See {@link PendingIntent#send(int, PendingIntent.OnFinished, Handler)}.
+     */
+    @SuppressLint("LambdaLast")  // compat shim so arguments should be in the same order
+    public static void send(
+            @NonNull PendingIntent pendingIntent,
+            int code,
+            PendingIntent.@Nullable OnFinished onFinished,
+            @Nullable Handler handler) throws PendingIntent.CanceledException {
+        try (GatedCallback gatedCallback = new GatedCallback(onFinished)) {
+            pendingIntent.send(code, gatedCallback.getCallback(), handler);
+            gatedCallback.complete();
+        }
+    }
+
+    /**
+     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks
+     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was
+     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a
+     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and
+     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()
+     * completed successfully.
+     *
+     * <p>See {@link PendingIntent#send(Context, int, Intent, PendingIntent.OnFinished, Handler)}.
+     */
+    @SuppressLint("LambdaLast")  // compat shim so arguments must be in the same order
+    public static void send(
+            @NonNull PendingIntent pendingIntent,
+            // compat shim so arguments must be in the same order
+            @SuppressLint("ContextFirst") @NonNull Context context,
+            int code,
+            @NonNull Intent intent,
+            PendingIntent.@Nullable OnFinished onFinished,
+            @Nullable Handler handler) throws PendingIntent.CanceledException {
+        send(pendingIntent, context, code, intent, onFinished, handler, null, null);
+    }
+
+    /**
+     * {@link PendingIntent#send()} variants that support {@link PendingIntent.OnFinished} callbacks
+     * have a bug on many API levels that the callback may be invoked even if the PendingIntent was
+     * never sent (ie, such as if the PendingIntent was canceled, and the send() invocation threw a
+     * {@link PendingIntent.CanceledException}). Using this compatibility method fixes that bug and
+     * guarantees that {@link PendingIntent.OnFinished} callbacks will only be invoked if send()
+     * completed successfully.
+     *
+     * <p>See {@link
+     * PendingIntent#send(Context, int, Intent, PendingIntent.OnFinished, Handler, String, Bundle)}
+     */
+    @SuppressLint("LambdaLast")  // compat shim so arguments must be in the same order
+    public static void send(
+            @NonNull PendingIntent pendingIntent,
+            // compat shim so arguments must be in the same order
+            @SuppressLint("ContextFirst") @NonNull Context context,
+            int code,
+            @NonNull Intent intent,
+            PendingIntent.@Nullable OnFinished onFinished,
+            @Nullable Handler handler,
+            @Nullable String requiredPermissions,
+            @Nullable Bundle options) throws PendingIntent.CanceledException {
+        try (GatedCallback gatedCallback = new GatedCallback(onFinished)) {
+            if (VERSION.SDK_INT >= VERSION_CODES.M) {
+                Api23Impl.send(
+                        pendingIntent,
+                        context,
+                        code,
+                        intent,
+                        onFinished,
+                        handler,
+                        requiredPermissions,
+                        options);
+            } else {
+                pendingIntent.send(context, code, intent, gatedCallback.getCallback(), handler,
+                        requiredPermissions);
+            }
+            gatedCallback.complete();
+        }
+    }
+
+    static int addMutabilityFlags(boolean isMutable, int flags) {
+        if (isMutable) {
+            if (VERSION.SDK_INT >= 31) {
+                flags |= FLAG_MUTABLE;
+            }
+        } else {
+            if (VERSION.SDK_INT >= 23) {
+                flags |= FLAG_IMMUTABLE;
+            }
+        }
+
+        return flags;
+    }
+
+    private PendingIntentCompat() {}
+
+    @RequiresApi(23)
+    private static class Api23Impl {
+        private Api23Impl() {}
+
+        public static void send(
+                @NonNull PendingIntent pendingIntent,
+                @NonNull Context context,
+                int code,
+                @NonNull Intent intent,
+                PendingIntent.@Nullable OnFinished onFinished,
+                @Nullable Handler handler,
+                @Nullable String requiredPermission,
+                @Nullable Bundle options) throws PendingIntent.CanceledException {
+            pendingIntent.send(
+                    context,
+                    code,
+                    intent,
+                    onFinished,
+                    handler,
+                    requiredPermission,
+                    options);
+        }
+    }
+
+    @RequiresApi(26)
+    private static class Api26Impl {
+        private Api26Impl() {}
+
+        public static PendingIntent getForegroundService(
+                Context context, int requestCode, Intent intent, int flags) {
+            return PendingIntent.getForegroundService(context, requestCode, intent, flags);
+        }
+    }
+
+    // see b/201299281 for more info and context
+    private static class GatedCallback implements Closeable {
+
+        private final CountDownLatch mComplete = new CountDownLatch(1);
+
+        private PendingIntent.@Nullable OnFinished mCallback;
+        private boolean mSuccess;
+
+        GatedCallback(PendingIntent.@Nullable OnFinished callback) {
+            this.mCallback = callback;
+            mSuccess = false;
+        }
+
+        public PendingIntent.@Nullable OnFinished getCallback() {
+            if (mCallback == null) {
+                return null;
+            } else {
+                return this::onSendFinished;
+            }
+        }
+
+        public void complete() {
+            mSuccess = true;
+        }
+
+        @Override
+        public void close() {
+            if (!mSuccess) {
+                mCallback = null;
+            }
+            mComplete.countDown();
+        }
+
+        private void onSendFinished(
+                PendingIntent pendingIntent,
+                Intent intent,
+                int resultCode,
+                String resultData,
+                Bundle resultExtras) {
+            boolean interrupted = false;
+            try {
+                while (true) {
+                    try {
+                        mComplete.await();
+                        break;
+                    } catch (InterruptedException e) {
+                        interrupted = true;
+                    }
+                }
+            } finally {
+                if (interrupted) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+
+            if (mCallback != null) {
+                mCallback.onSendFinished(
+                        pendingIntent,
+                        intent,
+                        resultCode,
+                        resultData,
+                        resultExtras);
+                mCallback = null;
+            }
+        }
+    }
+}
diff --git a/core/core/src/main/java/androidx/core/app/Person.java b/core/core/src/main/java/androidx/core/app/Person.java
index 441de1b..db0a07c 100644
--- a/core/core/src/main/java/androidx/core/app/Person.java
+++ b/core/core/src/main/java/androidx/core/app/Person.java
@@ -21,12 +21,13 @@
 import android.os.Bundle;
 import android.os.PersistableBundle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.graphics.drawable.IconCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Objects;
 
 /**
@@ -45,8 +46,7 @@
      * Extracts and returns the {@link Person} written to the {@code bundle}. A bundle can be
      * created from a {@link Person} using {@link #toBundle()}.
      */
-    @NonNull
-    public static Person fromBundle(@NonNull Bundle bundle) {
+    public static @NonNull Person fromBundle(@NonNull Bundle bundle) {
         Bundle iconBundle = bundle.getBundle(ICON_KEY);
         return new Builder()
                 .setName(bundle.getCharSequence(NAME_KEY))
@@ -65,9 +65,8 @@
      *
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @NonNull
     @RequiresApi(22)
-    public static Person fromPersistableBundle(@NonNull PersistableBundle bundle) {
+    public static @NonNull Person fromPersistableBundle(@NonNull PersistableBundle bundle) {
         return Api22Impl.fromPersistableBundle(bundle);
     }
 
@@ -77,19 +76,18 @@
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @RequiresApi(28)
-    @NonNull
-    public static Person fromAndroidPerson(@NonNull android.app.Person person) {
+    public static @NonNull Person fromAndroidPerson(android.app.@NonNull Person person) {
         return Api28Impl.fromAndroidPerson(person);
     }
 
     @SuppressWarnings("WeakerAccess") /* synthetic access */
-    @Nullable CharSequence mName;
+@Nullable CharSequence mName;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
-    @Nullable IconCompat mIcon;
+@Nullable IconCompat mIcon;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
-    @Nullable String mUri;
+@Nullable String mUri;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
-    @Nullable String mKey;
+@Nullable String mKey;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
     boolean mIsBot;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
@@ -109,8 +107,7 @@
      * Writes and returns a new {@link Bundle} that represents this {@link Person}. This bundle can
      * be converted back by using {@link #fromBundle(Bundle)}.
      */
-    @NonNull
-    public Bundle toBundle() {
+    public @NonNull Bundle toBundle() {
         Bundle result = new Bundle();
         result.putCharSequence(NAME_KEY, mName);
         result.putBundle(ICON_KEY, mIcon != null ? mIcon.toBundle() : null);
@@ -128,15 +125,13 @@
      *
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @NonNull
     @RequiresApi(22)
-    public PersistableBundle toPersistableBundle() {
+    public @NonNull PersistableBundle toPersistableBundle() {
         return Api22Impl.toPersistableBundle(this);
     }
 
     /** Creates and returns a new {@link Builder} initialized with this Person's data. */
-    @NonNull
-    public Builder toBuilder() {
+    public @NonNull Builder toBuilder() {
         return new Builder(this);
     }
 
@@ -145,9 +140,8 @@
      *
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @NonNull
     @RequiresApi(28)
-    public android.app.Person toAndroidPerson() {
+    public android.app.@NonNull Person toAndroidPerson() {
         return Api28Impl.toAndroidPerson(this);
     }
 
@@ -155,14 +149,12 @@
      * Returns the name for this {@link Person} or {@code null} if no name was provided. This could
      * be a full name, nickname, username, etc.
      */
-    @Nullable
-    public CharSequence getName() {
+    public @Nullable CharSequence getName() {
         return mName;
     }
 
     /** Returns the icon for this {@link Person} or {@code null} if no icon was provided. */
-    @Nullable
-    public IconCompat getIcon() {
+    public @Nullable IconCompat getIcon() {
         return mIcon;
     }
 
@@ -179,8 +171,7 @@
      * <p>*Note for these schemas, the path portion of the URI must exist in the contacts
      * database in their appropriate column, otherwise the reference should be discarded.
      */
-    @Nullable
-    public String getUri() {
+    public @Nullable String getUri() {
         return mUri;
     }
 
@@ -188,8 +179,7 @@
      * Returns the key for this {@link Person} or {@code null} if no key was provided. This is
      * provided as a unique identifier between other {@link Person}s.
      */
-    @Nullable
-    public String getKey() {
+    public @Nullable String getKey() {
         return mKey;
     }
 
@@ -212,9 +202,8 @@
     /**
      * @return the URI associated with this person, or "name:mName" otherwise
      */
-    @NonNull
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public String resolveToLegacyUri() {
+    public @NonNull String resolveToLegacyUri() {
         if (mUri != null) {
             return mUri;
         }
@@ -292,8 +281,7 @@
          * Give this {@link Person} a name to use for display. This can be, for example, a full
          * name, nickname, username, etc.
          */
-        @NonNull
-        public Builder setName(@Nullable CharSequence name) {
+        public @NonNull Builder setName(@Nullable CharSequence name) {
             mName = name;
             return this;
         }
@@ -304,8 +292,7 @@
          * <p>The system will prefer this icon over any images that are resolved from
          * {@link #setUri(String)}.
          */
-        @NonNull
-        public Builder setIcon(@Nullable IconCompat icon) {
+        public @NonNull Builder setIcon(@Nullable IconCompat icon) {
             mIcon = icon;
             return this;
         }
@@ -322,8 +309,7 @@
          * <p>*Note for these schemas, the path portion of the URI must exist in the contacts
          * database in their appropriate column, otherwise the reference will be discarded.
          */
-        @NonNull
-        public Builder setUri(@Nullable String uri) {
+        public @NonNull Builder setUri(@Nullable String uri) {
             mUri = uri;
             return this;
         }
@@ -333,8 +319,7 @@
          * {@link #setName(CharSequence)} value isn't unique. This value is preferred for
          * identification, but if it's not provided, the person's name will be used in its place.
          */
-        @NonNull
-        public Builder setKey(@Nullable String key) {
+        public @NonNull Builder setKey(@Nullable String key) {
             mKey = key;
             return this;
         }
@@ -343,8 +328,7 @@
          * Sets whether or not this {@link Person} represents a machine rather than a human. This is
          * used primarily for testing and automated tooling.
          */
-        @NonNull
-        public Builder setBot(boolean bot) {
+        public @NonNull Builder setBot(boolean bot) {
             mIsBot = bot;
             return this;
         }
@@ -355,15 +339,13 @@
          * {@link android.provider.ContactsContract.Contacts#CONTENT_LOOKUP_URI}, and instead with
          * the {@code mailto:} or {@code tel:} schemas.
          */
-        @NonNull
-        public Builder setImportant(boolean important) {
+        public @NonNull Builder setImportant(boolean important) {
             mIsImportant = important;
             return this;
         }
 
         /** Creates and returns the {@link Person} this builder represents. */
-        @NonNull
-        public Person build() {
+        public @NonNull Person build() {
             return new Person(this);
         }
     }
diff --git a/core/core/src/main/java/androidx/core/app/RemoteActionCompat.java b/core/core/src/main/java/androidx/core/app/RemoteActionCompat.java
index 120350c..01610ec 100644
--- a/core/core/src/main/java/androidx/core/app/RemoteActionCompat.java
+++ b/core/core/src/main/java/androidx/core/app/RemoteActionCompat.java
@@ -24,7 +24,6 @@
 import android.graphics.drawable.Icon;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.graphics.drawable.IconCompat;
@@ -33,6 +32,8 @@
 import androidx.versionedparcelable.VersionedParcelable;
 import androidx.versionedparcelable.VersionedParcelize;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Represents a remote action that can be called from another process.  The action can have an
  * associated visualization including metadata like an icon or title.
@@ -44,31 +45,27 @@
     /**
      */
     @SuppressWarnings("NotNullFieldNotInitialized") // VersionedParceleble inits this field.
-    @NonNull
     @RestrictTo(LIBRARY_GROUP)
     @ParcelField(1)
-    public IconCompat mIcon;
+    public @NonNull IconCompat mIcon;
     /**
      */
     @SuppressWarnings("NotNullFieldNotInitialized") // VersionedParceleble inits this field.
-    @NonNull
     @RestrictTo(LIBRARY_GROUP)
     @ParcelField(2)
-    public CharSequence mTitle;
+    public @NonNull CharSequence mTitle;
     /**
      */
     @SuppressWarnings("NotNullFieldNotInitialized") // VersionedParceleble inits this field.
-    @NonNull
     @RestrictTo(LIBRARY_GROUP)
     @ParcelField(3)
-    public CharSequence mContentDescription;
+    public @NonNull CharSequence mContentDescription;
     /**
      */
     @SuppressWarnings("NotNullFieldNotInitialized") // VersionedParceleble inits this field.
-    @NonNull
     @RestrictTo(LIBRARY_GROUP)
     @ParcelField(4)
-    public PendingIntent mActionIntent;
+    public @NonNull PendingIntent mActionIntent;
     /**
      */
     @RestrictTo(LIBRARY_GROUP)
@@ -113,8 +110,8 @@
      * Creates an RemoteActionCompat from a RemoteAction.
      */
     @RequiresApi(26)
-    @NonNull
-    public static RemoteActionCompat createFromRemoteAction(@NonNull RemoteAction remoteAction) {
+    public static @NonNull RemoteActionCompat createFromRemoteAction(
+            @NonNull RemoteAction remoteAction) {
         Preconditions.checkNotNull(remoteAction);
         RemoteActionCompat action = new RemoteActionCompat(IconCompat.createFromIcon(
                 Api26Impl.getIcon(remoteAction)),
@@ -192,8 +189,7 @@
      */
     @SuppressWarnings("deprecation")
     @RequiresApi(26)
-    @NonNull
-    public RemoteAction toRemoteAction() {
+    public @NonNull RemoteAction toRemoteAction() {
         RemoteAction action = Api26Impl.createRemoteAction(mIcon.toIcon(), mTitle,
                 mContentDescription, mActionIntent);
         Api26Impl.setEnabled(action, isEnabled());
diff --git a/core/core/src/main/java/androidx/core/app/RemoteInput.java b/core/core/src/main/java/androidx/core/app/RemoteInput.java
index 4dd86fc..634ea0d9 100644
--- a/core/core/src/main/java/androidx/core/app/RemoteInput.java
+++ b/core/core/src/main/java/androidx/core/app/RemoteInput.java
@@ -24,11 +24,12 @@
 import android.os.Bundle;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.HashMap;
@@ -109,16 +110,14 @@
      * Get the key that the result of this input will be set in from the Bundle returned by
      * {@link #getResultsFromIntent} when the {@link android.app.PendingIntent} is sent.
      */
-    @NonNull
-    public String getResultKey() {
+    public @NonNull String getResultKey() {
         return mResultKey;
     }
 
     /**
      * Get the label to display to users when collecting this input.
      */
-    @Nullable
-    public CharSequence getLabel() {
+    public @Nullable CharSequence getLabel() {
         return mLabel;
     }
 
@@ -126,14 +125,12 @@
      * Get possible input choices. This can be {@code null} if there are no choices to present.
      */
     @SuppressWarnings("NullableCollection") // Look, it's not the best API.
-    @Nullable
-    public CharSequence[] getChoices() {
+    public CharSequence @Nullable [] getChoices() {
         return mChoices;
     }
 
     @SuppressWarnings("NullableCollection") // That's just how it was defined.
-    @Nullable
-    public Set<String> getAllowedDataTypes() {
+    public @Nullable Set<String> getAllowedDataTypes() {
         return mAllowedDataTypes;
     }
 
@@ -170,8 +167,7 @@
     /**
      * Get additional metadata carried around with this remote input.
      */
-    @NonNull
-    public Bundle getExtras() {
+    public @NonNull Bundle getExtras() {
         return mExtras;
     }
 
@@ -206,8 +202,7 @@
          * @param label The label to show to users when they input a response
          * @return this object for method chaining
          */
-        @NonNull
-        public Builder setLabel(@Nullable CharSequence label) {
+        public @NonNull Builder setLabel(@Nullable CharSequence label) {
             mLabel = label;
             return this;
         }
@@ -224,8 +219,7 @@
          *        you disabled free form input using {@link #setAllowFreeFormInput}
          * @return this object for method chaining
          */
-        @NonNull
-        public Builder setChoices(@Nullable CharSequence[] choices) {
+        public @NonNull Builder setChoices(CharSequence @Nullable [] choices) {
             mChoices = choices;
             return this;
         }
@@ -240,8 +234,7 @@
          * @param doAllow Whether the mime type should be allowed or not
          * @return this object for method chaining
          */
-        @NonNull
-        public Builder setAllowDataType(@NonNull String mimeType, boolean doAllow) {
+        public @NonNull Builder setAllowDataType(@NonNull String mimeType, boolean doAllow) {
             if (doAllow) {
                 mAllowedDataTypes.add(mimeType);
             } else {
@@ -260,8 +253,7 @@
          *         {@link IllegalArgumentException} is thrown
          * @return this object for method chaining
          */
-        @NonNull
-        public Builder setAllowFreeFormInput(boolean allowFreeFormTextInput) {
+        public @NonNull Builder setAllowFreeFormInput(boolean allowFreeFormTextInput) {
             mAllowFreeFormTextInput = allowFreeFormTextInput;
             return this;
         }
@@ -272,8 +264,7 @@
          *
          * It cannot be used if {@link #setAllowFreeFormInput} has been set to false.
          */
-        @NonNull
-        public Builder setEditChoicesBeforeSending(
+        public @NonNull Builder setEditChoicesBeforeSending(
                 @EditChoicesBeforeSending int editChoicesBeforeSending) {
             mEditChoicesBeforeSending = editChoicesBeforeSending;
             return this;
@@ -286,8 +277,7 @@
          *
          * @see RemoteInput#getExtras
          */
-        @NonNull
-        public Builder addExtras(@NonNull Bundle extras) {
+        public @NonNull Builder addExtras(@NonNull Bundle extras) {
             if (extras != null) {
                 mExtras.putAll(extras);
             }
@@ -299,8 +289,7 @@
          *
          * <p>The returned Bundle is shared with this Builder.
          */
-        @NonNull
-        public Bundle getExtras() {
+        public @NonNull Bundle getExtras() {
             return mExtras;
         }
 
@@ -308,8 +297,7 @@
          * Combine all of the options that have been set and return a new {@link
          * androidx.core.app.RemoteInput} object.
          */
-        @NonNull
-        public RemoteInput build() {
+        public @NonNull RemoteInput build() {
             return new RemoteInput(
                     mResultKey,
                     mLabel,
@@ -338,8 +326,7 @@
      * @param remoteInputResultKey The result key for the RemoteInput you want results for.
      */
     @SuppressWarnings("NullableCollection") // This is what the platform API does.
-    @Nullable
-    public static Map<String, Uri> getDataResultsFromIntent(
+    public static @Nullable Map<String, Uri> getDataResultsFromIntent(
             @NonNull Intent intent, @NonNull String remoteInputResultKey) {
         if (Build.VERSION.SDK_INT >= 26) {
             return Api26Impl.getDataResultsFromIntent(intent, remoteInputResultKey);
@@ -378,8 +365,7 @@
      */
     // This is on purpose.
     @SuppressWarnings({"NullableCollection", "deprecation"})
-    @Nullable
-    public static Bundle getResultsFromIntent(@NonNull Intent intent) {
+    public static @Nullable Bundle getResultsFromIntent(@NonNull Intent intent) {
         if (Build.VERSION.SDK_INT >= 20) {
             return Api20Impl.getResultsFromIntent(intent);
         } else {
@@ -403,7 +389,7 @@
      *                {@code remoteInputs} with values being the result per key.
      */
     @SuppressWarnings("deprecation")
-    public static void addResultsToIntent(@NonNull RemoteInput[] remoteInputs,
+    public static void addResultsToIntent(RemoteInput @NonNull [] remoteInputs,
             @NonNull Intent intent, @NonNull Bundle results) {
         if (Build.VERSION.SDK_INT >= 26) {
             Api20Impl.addResultsToIntent(fromCompat(remoteInputs), intent, results);
diff --git a/core/core/src/main/java/androidx/core/app/ServiceCompat.java b/core/core/src/main/java/androidx/core/app/ServiceCompat.java
index 444e0c0..e1b8891 100644
--- a/core/core/src/main/java/androidx/core/app/ServiceCompat.java
+++ b/core/core/src/main/java/androidx/core/app/ServiceCompat.java
@@ -27,10 +27,11 @@
 import android.os.Build;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/app/ShareCompat.java b/core/core/src/main/java/androidx/core/app/ShareCompat.java
index 818210a..e54c4d8 100644
--- a/core/core/src/main/java/androidx/core/app/ShareCompat.java
+++ b/core/core/src/main/java/androidx/core/app/ShareCompat.java
@@ -37,11 +37,12 @@
 import android.widget.ShareActionProvider;
 
 import androidx.annotation.IdRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.core.content.IntentCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
@@ -126,8 +127,7 @@
      * @param calledActivity Current activity that was launched to share content
      * @return Name of the calling package
      */
-    @Nullable
-    public static String getCallingPackage(@NonNull Activity calledActivity) {
+    public static @Nullable String getCallingPackage(@NonNull Activity calledActivity) {
         Intent intent = calledActivity.getIntent();
         String result = calledActivity.getCallingPackage();
         if (result == null && intent != null) {
@@ -149,8 +149,7 @@
      * @return Name of the calling package
      */
     @SuppressWarnings("WeakerAccess")
-    @Nullable
-    static String getCallingPackage(@NonNull Intent intent) {
+    static @Nullable String getCallingPackage(@NonNull Intent intent) {
         String result = intent.getStringExtra(EXTRA_CALLING_PACKAGE);
         if (result == null) {
             result = intent.getStringExtra(EXTRA_CALLING_PACKAGE_INTEROP);
@@ -170,8 +169,7 @@
      * @param calledActivity Current activity that was launched to share content
      * @return ComponentName of the calling activity
      */
-    @Nullable
-    public static ComponentName getCallingActivity(@NonNull Activity calledActivity) {
+    public static @Nullable ComponentName getCallingActivity(@NonNull Activity calledActivity) {
         Intent intent = calledActivity.getIntent();
         ComponentName result = calledActivity.getCallingActivity();
         if (result == null) {
@@ -193,8 +191,7 @@
      * @return ComponentName of the calling activity
      */
     @SuppressWarnings({"WeakerAccess", "deprecation"})
-    @Nullable
-    static ComponentName getCallingActivity(@NonNull Intent intent) {
+    static @Nullable ComponentName getCallingActivity(@NonNull Intent intent) {
         ComponentName result = intent.getParcelableExtra(EXTRA_CALLING_ACTIVITY);
         if (result == null) {
             result = intent.getParcelableExtra(EXTRA_CALLING_ACTIVITY_INTEROP);
@@ -291,9 +288,8 @@
          * @return a new IntentBuilder instance
          * @deprecated Use the constructor of IntentBuilder
          */
-        @NonNull
         @Deprecated
-        public static IntentBuilder from(@NonNull Activity launchingActivity) {
+        public static @NonNull IntentBuilder from(@NonNull Activity launchingActivity) {
             return new IntentBuilder(launchingActivity);
         }
 
@@ -339,8 +335,7 @@
          *
          * @return The current Intent being configured by this builder
          */
-        @NonNull
-        public Intent getIntent() {
+        public @NonNull Intent getIntent() {
             if (mToAddresses != null) {
                 combineArrayExtra(Intent.EXTRA_EMAIL, mToAddresses);
                 mToAddresses = null;
@@ -375,8 +370,7 @@
             return mIntent;
         }
 
-        @NonNull
-        Context getContext() {
+        @NonNull Context getContext() {
             return mContext;
         }
 
@@ -391,7 +385,7 @@
             mIntent.putExtra(extra, finalAddresses);
         }
 
-        private void combineArrayExtra(@Nullable String extra, @NonNull String[] add) {
+        private void combineArrayExtra(@Nullable String extra, String @NonNull [] add) {
             // Add any items still pending
             Intent intent = getIntent();
             String[] old = intent.getStringArrayExtra(extra);
@@ -409,8 +403,7 @@
          *
          * @return A chooser Intent for the currently configured sharing action
          */
-        @NonNull
-        public Intent createChooserIntent() {
+        public @NonNull Intent createChooserIntent() {
             return Intent.createChooser(getIntent(), mChooserTitle);
         }
 
@@ -427,8 +420,7 @@
          * @param title Title string
          * @return This IntentBuilder for method chaining
          */
-        @NonNull
-        public IntentBuilder setChooserTitle(@Nullable CharSequence title) {
+        public @NonNull IntentBuilder setChooserTitle(@Nullable CharSequence title) {
             mChooserTitle = title;
             return this;
         }
@@ -439,8 +431,7 @@
          * @param resId Resource ID of the title string to use
          * @return This IntentBuilder for method chaining
          */
-        @NonNull
-        public IntentBuilder setChooserTitle(@StringRes int resId) {
+        public @NonNull IntentBuilder setChooserTitle(@StringRes int resId) {
             return setChooserTitle(mContext.getText(resId));
         }
 
@@ -451,8 +442,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#setType(String)
          */
-        @NonNull
-        public IntentBuilder setType(@Nullable String mimeType) {
+        public @NonNull IntentBuilder setType(@Nullable String mimeType) {
             mIntent.setType(mimeType);
             return this;
         }
@@ -465,8 +455,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_TEXT
          */
-        @NonNull
-        public IntentBuilder setText(@Nullable CharSequence text) {
+        public @NonNull IntentBuilder setText(@Nullable CharSequence text) {
             mIntent.putExtra(Intent.EXTRA_TEXT, text);
             return this;
         }
@@ -482,8 +471,7 @@
          * @return This IntentBuilder for method chaining
          * @see #setText(CharSequence)
          */
-        @NonNull
-        public IntentBuilder setHtmlText(@Nullable String htmlText) {
+        public @NonNull IntentBuilder setHtmlText(@Nullable String htmlText) {
             mIntent.putExtra(IntentCompat.EXTRA_HTML_TEXT, htmlText);
             if (!mIntent.hasExtra(Intent.EXTRA_TEXT)) {
                 // Supply a default if EXTRA_TEXT isn't set
@@ -502,8 +490,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_STREAM
          */
-        @NonNull
-        public IntentBuilder setStream(@Nullable Uri streamUri) {
+        public @NonNull IntentBuilder setStream(@Nullable Uri streamUri) {
             mStreams = null;
             if (streamUri != null) {
                 addStream(streamUri);
@@ -522,8 +509,7 @@
          * @see Intent#ACTION_SEND
          * @see Intent#ACTION_SEND_MULTIPLE
          */
-        @NonNull
-        public IntentBuilder addStream(@NonNull Uri streamUri) {
+        public @NonNull IntentBuilder addStream(@NonNull Uri streamUri) {
             if (mStreams == null) {
                 mStreams = new ArrayList<>();
             }
@@ -539,8 +525,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_EMAIL
          */
-        @NonNull
-        public IntentBuilder setEmailTo(@Nullable String[] addresses) {
+        public @NonNull IntentBuilder setEmailTo(String @Nullable [] addresses) {
             if (mToAddresses != null) {
                 mToAddresses = null;
             }
@@ -555,8 +540,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_EMAIL
          */
-        @NonNull
-        public IntentBuilder addEmailTo(@NonNull String address) {
+        public @NonNull IntentBuilder addEmailTo(@NonNull String address) {
             if (mToAddresses == null) {
                 mToAddresses = new ArrayList<>();
             }
@@ -571,8 +555,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_EMAIL
          */
-        @NonNull
-        public IntentBuilder addEmailTo(@NonNull String[] addresses) {
+        public @NonNull IntentBuilder addEmailTo(String @NonNull [] addresses) {
             combineArrayExtra(Intent.EXTRA_EMAIL, addresses);
             return this;
         }
@@ -585,8 +568,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_CC
          */
-        @NonNull
-        public IntentBuilder setEmailCc(@Nullable String[] addresses) {
+        public @NonNull IntentBuilder setEmailCc(String @Nullable [] addresses) {
             mIntent.putExtra(Intent.EXTRA_CC, addresses);
             return this;
         }
@@ -598,8 +580,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_CC
          */
-        @NonNull
-        public IntentBuilder addEmailCc(@NonNull String address) {
+        public @NonNull IntentBuilder addEmailCc(@NonNull String address) {
             if (mCcAddresses == null) {
                 mCcAddresses = new ArrayList<>();
             }
@@ -614,8 +595,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_CC
          */
-        @NonNull
-        public IntentBuilder addEmailCc(@NonNull String[] addresses) {
+        public @NonNull IntentBuilder addEmailCc(String @NonNull [] addresses) {
             combineArrayExtra(Intent.EXTRA_CC, addresses);
             return this;
         }
@@ -628,8 +608,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_BCC
          */
-        @NonNull
-        public IntentBuilder setEmailBcc(@Nullable String[] addresses) {
+        public @NonNull IntentBuilder setEmailBcc(String @Nullable [] addresses) {
             mIntent.putExtra(Intent.EXTRA_BCC, addresses);
             return this;
         }
@@ -641,8 +620,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_BCC
          */
-        @NonNull
-        public IntentBuilder addEmailBcc(@NonNull String address) {
+        public @NonNull IntentBuilder addEmailBcc(@NonNull String address) {
             if (mBccAddresses == null) {
                 mBccAddresses = new ArrayList<>();
             }
@@ -657,8 +635,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_BCC
          */
-        @NonNull
-        public IntentBuilder addEmailBcc(@NonNull String[] addresses) {
+        public @NonNull IntentBuilder addEmailBcc(String @NonNull [] addresses) {
             combineArrayExtra(Intent.EXTRA_BCC, addresses);
             return this;
         }
@@ -670,8 +647,7 @@
          * @return This IntentBuilder for method chaining
          * @see Intent#EXTRA_SUBJECT
          */
-        @NonNull
-        public IntentBuilder setSubject(@Nullable String subject) {
+        public @NonNull IntentBuilder setSubject(@Nullable String subject) {
             mIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
             return this;
         }
@@ -712,9 +688,8 @@
          * @return IntentReader for parsing sharing data
          * @deprecated Use the constructor of IntentReader instead
          */
-        @NonNull
         @Deprecated
-        public static IntentReader from(@NonNull Activity activity) {
+        public static @NonNull IntentReader from(@NonNull Activity activity) {
             return new IntentReader(activity);
         }
 
@@ -784,8 +759,7 @@
          * @return mimetype of the shared data
          * @see Intent#getType()
          */
-        @Nullable
-        public String getType() {
+        public @Nullable String getType() {
             return mIntent.getType();
         }
 
@@ -795,8 +769,7 @@
          * @return Literal shared text or null if none was supplied
          * @see Intent#EXTRA_TEXT
          */
-        @Nullable
-        public CharSequence getText() {
+        public @Nullable CharSequence getText() {
             return mIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
         }
 
@@ -810,8 +783,7 @@
          *
          * @return Styled text provided by the sender as HTML.
          */
-        @Nullable
-        public String getHtmlText() {
+        public @Nullable String getHtmlText() {
             String result = mIntent.getStringExtra(IntentCompat.EXTRA_HTML_TEXT);
             if (result == null) {
                 CharSequence text = getText();
@@ -836,8 +808,7 @@
          * @see Intent#EXTRA_STREAM
          */
         @SuppressWarnings("deprecation")
-        @Nullable
-        public Uri getStream() {
+        public @Nullable Uri getStream() {
             return mIntent.getParcelableExtra(Intent.EXTRA_STREAM);
         }
 
@@ -851,8 +822,7 @@
          * @see Intent#ACTION_SEND_MULTIPLE
          */
         @SuppressWarnings("deprecation")
-        @Nullable
-        public Uri getStream(int index) {
+        public @Nullable Uri getStream(int index) {
             if (mStreams == null && isMultipleShare()) {
                 mStreams = mIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
             }
@@ -890,8 +860,7 @@
          * @return An array of email addresses or null if none were supplied.
          * @see Intent#EXTRA_EMAIL
          */
-        @Nullable
-        public String[] getEmailTo() {
+        public String @Nullable [] getEmailTo() {
             return mIntent.getStringArrayExtra(Intent.EXTRA_EMAIL);
         }
 
@@ -901,8 +870,7 @@
          * @return An array of email addresses or null if none were supplied.
          * @see Intent#EXTRA_CC
          */
-        @Nullable
-        public String[] getEmailCc() {
+        public String @Nullable [] getEmailCc() {
             return mIntent.getStringArrayExtra(Intent.EXTRA_CC);
         }
 
@@ -912,8 +880,7 @@
          * @return An array of email addresses or null if none were supplied.
          * @see Intent#EXTRA_BCC
          */
-        @Nullable
-        public String[] getEmailBcc() {
+        public String @Nullable [] getEmailBcc() {
             return mIntent.getStringArrayExtra(Intent.EXTRA_BCC);
         }
 
@@ -923,8 +890,7 @@
          * @return The subject heading for this share or null if one was not supplied.
          * @see Intent#EXTRA_SUBJECT
          */
-        @Nullable
-        public String getSubject() {
+        public @Nullable String getSubject() {
             return mIntent.getStringExtra(Intent.EXTRA_SUBJECT);
         }
 
@@ -942,8 +908,7 @@
          * @see ShareCompat#EXTRA_CALLING_PACKAGE
          * @see ShareCompat#EXTRA_CALLING_PACKAGE_INTEROP
          */
-        @Nullable
-        public String getCallingPackage() {
+        public @Nullable String getCallingPackage() {
             return mCallingPackage;
         }
 
@@ -961,8 +926,7 @@
          * @see ShareCompat#EXTRA_CALLING_ACTIVITY
          * @see ShareCompat#EXTRA_CALLING_ACTIVITY_INTEROP
          */
-        @Nullable
-        public ComponentName getCallingActivity() {
+        public @Nullable ComponentName getCallingActivity() {
             return mCallingActivity;
         }
 
@@ -976,8 +940,7 @@
          *
          * @return The calling Activity's icon or null if unknown
          */
-        @Nullable
-        public Drawable getCallingActivityIcon() {
+        public @Nullable Drawable getCallingActivityIcon() {
             if (mCallingActivity == null) return null;
 
             PackageManager pm = mContext.getPackageManager();
@@ -999,8 +962,7 @@
          *
          * @return The calling application's icon or null if unknown
          */
-        @Nullable
-        public Drawable getCallingApplicationIcon() {
+        public @Nullable Drawable getCallingApplicationIcon() {
             if (mCallingPackage == null) return null;
 
             PackageManager pm = mContext.getPackageManager();
@@ -1023,8 +985,7 @@
          * @return The calling application's label or null if unknown
          */
         @SuppressWarnings("deprecation")
-        @Nullable
-        public CharSequence getCallingApplicationLabel() {
+        public @Nullable CharSequence getCallingApplicationLabel() {
             if (mCallingPackage == null) return null;
 
             PackageManager pm = mContext.getPackageManager();
diff --git a/core/core/src/main/java/androidx/core/app/TaskStackBuilder.java b/core/core/src/main/java/androidx/core/app/TaskStackBuilder.java
index b077c0c..ee87201 100644
--- a/core/core/src/main/java/androidx/core/app/TaskStackBuilder.java
+++ b/core/core/src/main/java/androidx/core/app/TaskStackBuilder.java
@@ -25,10 +25,11 @@
 import android.os.Bundle;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.content.ContextCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Iterator;
 
@@ -71,8 +72,7 @@
     private static final String TAG = "TaskStackBuilder";
 
     public interface SupportParentable {
-        @Nullable
-        Intent getSupportParentActivityIntent();
+        @Nullable Intent getSupportParentActivityIntent();
     }
 
     private final ArrayList<Intent> mIntents = new ArrayList<>();
@@ -89,8 +89,7 @@
      * @param context The context that will launch the new task stack or generate a PendingIntent
      * @return A new TaskStackBuilder
      */
-    @NonNull
-    public static TaskStackBuilder create(@NonNull Context context) {
+    public static @NonNull TaskStackBuilder create(@NonNull Context context) {
         return new TaskStackBuilder(context);
     }
 
@@ -115,8 +114,7 @@
      * @param nextIntent Intent for the next Activity in the synthesized task stack
      * @return This TaskStackBuilder for method chaining
      */
-    @NonNull
-    public TaskStackBuilder addNextIntent(@NonNull Intent nextIntent) {
+    public @NonNull TaskStackBuilder addNextIntent(@NonNull Intent nextIntent) {
         mIntents.add(nextIntent);
         return this;
     }
@@ -133,8 +131,7 @@
      *                   Its chain of parents as specified in the manifest will be added.
      * @return This TaskStackBuilder for method chaining.
      */
-    @NonNull
-    public TaskStackBuilder addNextIntentWithParentStack(@NonNull Intent nextIntent) {
+    public @NonNull TaskStackBuilder addNextIntentWithParentStack(@NonNull Intent nextIntent) {
         ComponentName target = nextIntent.getComponent();
         if (target == null) {
             target = nextIntent.resolveActivity(mSourceContext.getPackageManager());
@@ -153,8 +150,7 @@
      * @param sourceActivity All parents of this activity will be added
      * @return This TaskStackBuilder for method chaining
      */
-    @NonNull
-    public TaskStackBuilder addParentStack(@NonNull Activity sourceActivity) {
+    public @NonNull TaskStackBuilder addParentStack(@NonNull Activity sourceActivity) {
         Intent parent = null;
         if (sourceActivity instanceof SupportParentable) {
             parent = ((SupportParentable) sourceActivity).getSupportParentActivityIntent();
@@ -183,8 +179,7 @@
      * @param sourceActivityClass All parents of this activity will be added
      * @return This TaskStackBuilder for method chaining
      */
-    @NonNull
-    public TaskStackBuilder addParentStack(@NonNull Class<?> sourceActivityClass) {
+    public @NonNull TaskStackBuilder addParentStack(@NonNull Class<?> sourceActivityClass) {
         return addParentStack(new ComponentName(mSourceContext, sourceActivityClass));
     }
 
@@ -196,8 +191,7 @@
      *                           this activity will be added
      * @return This TaskStackBuilder for method chaining
      */
-    @NonNull
-    public TaskStackBuilder addParentStack(@NonNull ComponentName sourceActivityName) {
+    public @NonNull TaskStackBuilder addParentStack(@NonNull ComponentName sourceActivityName) {
         final int insertAt = mIntents.size();
         try {
             Intent parent = NavUtils.getParentActivityIntent(mSourceContext, sourceActivityName);
@@ -242,18 +236,16 @@
      * @param index Index from 0-getIntentCount()
      * @return the intent at position index
      */
-    @Nullable
-    public Intent editIntentAt(int index) {
+    public @Nullable Intent editIntentAt(int index) {
         return mIntents.get(index);
     }
 
     /**
      * @deprecated Use editIntentAt instead
      */
-    @NonNull
     @Override
     @Deprecated
-    public Iterator<Intent> iterator() {
+    public @NonNull Iterator<Intent> iterator() {
         return mIntents.iterator();
     }
 
@@ -308,8 +300,7 @@
      * @return The obtained PendingIntent.  May return null only if
      * {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
      */
-    @Nullable
-    public PendingIntent getPendingIntent(int requestCode, int flags) {
+    public @Nullable PendingIntent getPendingIntent(int requestCode, int flags) {
         return getPendingIntent(requestCode, flags, null);
     }
 
@@ -319,8 +310,7 @@
      * mutability flag. This method combines mutability flag when necessary. See {@link
      * TaskStackBuilder#getPendingIntent(int, int)}.
      */
-    @Nullable
-    public PendingIntent getPendingIntent(
+    public @Nullable PendingIntent getPendingIntent(
             int requestCode,
             int flags,
             boolean isMutable) {
@@ -343,8 +333,8 @@
      * @return The obtained PendingIntent.  May return null only if
      * {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
      */
-    @Nullable
-    public PendingIntent getPendingIntent(int requestCode, int flags, @Nullable Bundle options) {
+    public @Nullable PendingIntent getPendingIntent(int requestCode, int flags,
+            @Nullable Bundle options) {
         if (mIntents.isEmpty()) {
             throw new IllegalStateException(
                     "No intents added to TaskStackBuilder; cannot getPendingIntent");
@@ -363,8 +353,7 @@
      * mutability flag. This method combines mutability flag when necessary. See {@link
      * TaskStackBuilder#getPendingIntent(int, int, Bundle)}.
      */
-    @Nullable
-    public PendingIntent getPendingIntent(
+    public @Nullable PendingIntent getPendingIntent(
             int requestCode,
             int flags,
             @Nullable Bundle options,
@@ -382,8 +371,7 @@
      *
      * @return An array containing the intents added to this builder.
      */
-    @NonNull
-    public Intent[] getIntents() {
+    public Intent @NonNull [] getIntents() {
         Intent[] intents = new Intent[mIntents.size()];
         if (intents.length == 0) return intents;
 
diff --git a/core/core/src/main/java/androidx/core/content/ContentProviderCompat.java b/core/core/src/main/java/androidx/core/content/ContentProviderCompat.java
index 0d4f8b4..5cbbcd7 100644
--- a/core/core/src/main/java/androidx/core/content/ContentProviderCompat.java
+++ b/core/core/src/main/java/androidx/core/content/ContentProviderCompat.java
@@ -19,7 +19,7 @@
 import android.content.ContentProvider;
 import android.content.Context;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing features in {@link android.content.ContentProvider} in a backwards
@@ -41,7 +41,7 @@
      * @return The {@link android.content.Context} object associated with the
      * {@link android.content.ContentProvider}.
      */
-    public static @NonNull Context requireContext(@NonNull final ContentProvider provider) {
+    public static @NonNull Context requireContext(final @NonNull ContentProvider provider) {
         final Context ctx = provider.getContext();
         if (ctx == null) {
             throw new IllegalStateException("Cannot find context from the provider.");
diff --git a/core/core/src/main/java/androidx/core/content/ContentResolverCompat.java b/core/core/src/main/java/androidx/core/content/ContentResolverCompat.java
index c2fc5b2..2435568 100644
--- a/core/core/src/main/java/androidx/core/content/ContentResolverCompat.java
+++ b/core/core/src/main/java/androidx/core/content/ContentResolverCompat.java
@@ -21,10 +21,11 @@
 import android.net.Uri;
 import android.os.CancellationSignal;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.os.OperationCanceledException;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link ContentResolver} in a backwards
  * compatible fashion.
@@ -72,11 +73,10 @@
      * {@link #query(ContentResolver, Uri, String[], String, String[], String, CancellationSignal)}
      */
     @Deprecated
-    @Nullable
-    public static Cursor query(@NonNull ContentResolver resolver,
-            @NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
-            @Nullable String[] selectionArgs, @Nullable String sortOrder,
-            @Nullable androidx.core.os.CancellationSignal cancellationSignal) {
+    public static @Nullable Cursor query(@NonNull ContentResolver resolver,
+            @NonNull Uri uri, String @Nullable [] projection, @Nullable String selection,
+            String @Nullable [] selectionArgs, @Nullable String sortOrder,
+            androidx.core.os.@Nullable CancellationSignal cancellationSignal) {
         return query(resolver, uri, projection, selection, selectionArgs, sortOrder,
                 cancellationSignal != null
                         ? (CancellationSignal) cancellationSignal.getCancellationSignalObject() :
@@ -118,10 +118,9 @@
      * @return A Cursor object, which is positioned before the first entry, or null
      * @see Cursor
      */
-    @Nullable
-    public static Cursor query(@NonNull ContentResolver resolver,
-            @NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
-            @Nullable String[] selectionArgs, @Nullable String sortOrder,
+    public static @Nullable Cursor query(@NonNull ContentResolver resolver,
+            @NonNull Uri uri, String @Nullable [] projection, @Nullable String selection,
+            String @Nullable [] selectionArgs, @Nullable String sortOrder,
             @Nullable CancellationSignal cancellationSignal) {
         try {
             return resolver.query(uri, projection, selection, selectionArgs, sortOrder,
diff --git a/core/core/src/main/java/androidx/core/content/ContextCompat.java b/core/core/src/main/java/androidx/core/content/ContextCompat.java
index 076e416..b897682 100644
--- a/core/core/src/main/java/androidx/core/content/ContextCompat.java
+++ b/core/core/src/main/java/androidx/core/content/ContextCompat.java
@@ -145,8 +145,6 @@
 import androidx.annotation.DisplayContext;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.app.ActivityOptionsCompat;
@@ -159,6 +157,9 @@
 import androidx.core.os.LocaleListCompat;
 import androidx.core.util.ObjectsCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.File;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -235,7 +236,7 @@
      *                length-1 will correspond to the top activity on the resulting task stack.
      * @return true if the underlying API was available and the call was successful, false otherwise
      */
-    public static boolean startActivities(@NonNull Context context, @NonNull Intent[] intents) {
+    public static boolean startActivities(@NonNull Context context, Intent @NonNull [] intents) {
         return startActivities(context, intents, null);
     }
 
@@ -266,7 +267,7 @@
      *                See {@link Context#startActivity(Intent, Bundle)}
      * @return true if the underlying API was available and the call was successful, false otherwise
      */
-    public static boolean startActivities(@NonNull Context context, @NonNull Intent[] intents,
+    public static boolean startActivities(@NonNull Context context, Intent @NonNull [] intents,
             @Nullable Bundle options) {
         context.startActivities(intents, options);
         return true;
@@ -312,8 +313,7 @@
      *
      * @see ApplicationInfo#dataDir
      */
-    @Nullable
-    public static File getDataDir(@NonNull Context context) {
+    public static @Nullable File getDataDir(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.getDataDir(context);
         } else {
@@ -368,8 +368,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "context.getObbDirs()")
-    @NonNull
-    public static File[] getObbDirs(@NonNull Context context) {
+    public static File @NonNull [] getObbDirs(@NonNull Context context) {
         return context.getObbDirs();
     }
 
@@ -420,8 +419,8 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "context.getExternalFilesDirs(type)")
-    @NonNull
-    public static File[] getExternalFilesDirs(@NonNull Context context, @Nullable String type) {
+    public static File @NonNull [] getExternalFilesDirs(@NonNull Context context,
+            @Nullable String type) {
         return context.getExternalFilesDirs(type);
     }
 
@@ -472,8 +471,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "context.getExternalCacheDirs()")
-    @NonNull
-    public static File[] getExternalCacheDirs(@NonNull Context context) {
+    public static File @NonNull [] getExternalCacheDirs(@NonNull Context context) {
         return context.getExternalCacheDirs();
     }
 
@@ -490,8 +488,7 @@
      * @return Drawable An object that can be used to draw this resource.
      */
     @SuppressWarnings("deprecation")
-    @Nullable
-    public static Drawable getDrawable(@NonNull Context context, @DrawableRes int id) {
+    public static @Nullable Drawable getDrawable(@NonNull Context context, @DrawableRes int id) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getDrawable(context, id);
         } else {
@@ -514,8 +511,8 @@
      * @throws android.content.res.Resources.NotFoundException if the given ID
      *         does not exist.
      */
-    @Nullable
-    public static ColorStateList getColorStateList(@NonNull Context context, @ColorRes int id) {
+    public static @Nullable ColorStateList getColorStateList(@NonNull Context context,
+            @ColorRes int id) {
         return ResourcesCompat.getColorStateList(context.getResources(), id, context.getTheme());
     }
 
@@ -576,8 +573,7 @@
      * automatically backed up to remote storage.
      * @see Context#getFilesDir()
      */
-    @Nullable
-    public static File getNoBackupFilesDir(@NonNull Context context) {
+    public static @Nullable File getNoBackupFilesDir(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getNoBackupFilesDir(context);
         } else {
@@ -601,8 +597,7 @@
      *
      * @return The path of the directory holding application code cache files.
      */
-    @NonNull
-    public static File getCodeCacheDir(@NonNull Context context) {
+    public static @NonNull File getCodeCacheDir(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getCodeCacheDir(context);
         } else {
@@ -661,8 +656,7 @@
      *
      * @see ContextCompat#isDeviceProtectedStorage(Context)
      */
-    @Nullable
-    public static Context createDeviceProtectedStorageContext(@NonNull Context context) {
+    public static @Nullable Context createDeviceProtectedStorageContext(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.createDeviceProtectedStorageContext(context);
         } else {
@@ -689,8 +683,7 @@
      * thread associated with this context. This is the thread used to dispatch
      * calls to application components (activities, services, etc).
      */
-    @NonNull
-    public static Executor getMainExecutor(@NonNull Context context) {
+    public static @NonNull Executor getMainExecutor(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.getMainExecutor(context);
         }
@@ -729,8 +722,7 @@
      * @return The display associated with the Context or the default display if the context
      * doesn't associated with any display.
      */
-    @NonNull
-    public static Display getDisplayOrDefault(@NonNull @DisplayContext Context context) {
+    public static @NonNull Display getDisplayOrDefault(@DisplayContext @NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 30) {
             return Api30Impl.getDisplayOrDefault(context);
         } else {
@@ -749,8 +741,8 @@
      * @see Context#getSystemService(Class)
      */
     @SuppressWarnings("unchecked")
-    @Nullable
-    public static <T> T getSystemService(@NonNull Context context, @NonNull Class<T> serviceClass) {
+    public static <T> @Nullable T getSystemService(@NonNull Context context,
+            @NonNull Class<T> serviceClass) {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getSystemService(context, serviceClass);
         }
@@ -774,8 +766,7 @@
      * @see Context#registerReceiver(BroadcastReceiver, IntentFilter, int)
      * @see https://developer.android.com/develop/background-work/background-tasks/broadcasts#context-registered-receivers
      */
-    @Nullable
-    public static Intent registerReceiver(@NonNull Context context,
+    public static @Nullable Intent registerReceiver(@NonNull Context context,
             @Nullable BroadcastReceiver receiver, @NonNull IntentFilter filter,
             @RegisterReceiverFlags int flags) {
         return registerReceiver(context, receiver, filter, null, null, flags);
@@ -802,8 +793,7 @@
      * @see Context#registerReceiver(BroadcastReceiver, IntentFilter, String, Handler, int)
      * @see https://developer.android.com/develop/background-work/background-tasks/broadcasts#context-registered-receivers
      */
-    @Nullable
-    public static Intent registerReceiver(@NonNull Context context,
+    public static @Nullable Intent registerReceiver(@NonNull Context context,
             @Nullable BroadcastReceiver receiver, @NonNull IntentFilter filter,
             @Nullable String broadcastPermission,
             @Nullable Handler scheduler, @RegisterReceiverFlags int flags) {
@@ -851,8 +841,7 @@
      * @return The service name or null if the class is not a supported system service.
      * @see Context#getSystemServiceName(Class)
      */
-    @Nullable
-    public static String getSystemServiceName(@NonNull Context context,
+    public static @Nullable String getSystemServiceName(@NonNull Context context,
             @NonNull Class<?> serviceClass) {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getSystemServiceName(context, serviceClass);
@@ -877,8 +866,7 @@
      * </ul>
      * </p>
      */
-    @NonNull
-    public static String getString(@NonNull Context context, int resId) {
+    public static @NonNull String getString(@NonNull Context context, int resId) {
         return getContextForLanguage(context).getString(resId);
     }
 
@@ -902,8 +890,7 @@
      * </ul>
      * </p>
      */
-    @NonNull
-    public static Context getContextForLanguage(@NonNull Context context) {
+    public static @NonNull Context getContextForLanguage(@NonNull Context context) {
         LocaleListCompat locales = LocaleManagerCompat.getApplicationLocales(context);
 
         // The Android framework supports per-app locales on API 33, so we assume the
@@ -932,8 +919,7 @@
      *
      * @return the attribution tag this context is for or {@code null} if this is the default.
      */
-    @Nullable
-    public static String getAttributionTag(@NonNull Context context) {
+    public static @Nullable String getAttributionTag(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 30) {
             return Api30Impl.getAttributionTag(context);
         }
@@ -957,8 +943,7 @@
      * @return A {@link Context} that is tagged for the new attribution
      * @see #getAttributionTag(Context)
      */
-    @NonNull
-    public static Context createAttributionContext(@NonNull Context context,
+    public static @NonNull Context createAttributionContext(@NonNull Context context,
             @Nullable String attributionTag) {
         if (Build.VERSION.SDK_INT >= 30) {
             return Api30Impl.createAttributionContext(context, attributionTag);
@@ -1169,8 +1154,7 @@
             }
         }
 
-        @NonNull
-        static Context createAttributionContext(@NonNull Context context,
+        static @NonNull Context createAttributionContext(@NonNull Context context,
                 @Nullable String attributionTag) {
             return context.createAttributionContext(attributionTag);
         }
diff --git a/core/core/src/main/java/androidx/core/content/FileProvider.java b/core/core/src/main/java/androidx/core/content/FileProvider.java
index 123322e..b3b6c92 100644
--- a/core/core/src/main/java/androidx/core/content/FileProvider.java
+++ b/core/core/src/main/java/androidx/core/content/FileProvider.java
@@ -46,13 +46,13 @@
 
 import androidx.annotation.CallSuper;
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 import androidx.annotation.XmlRes;
 import androidx.core.content.res.ResourcesCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParserException;
 
 import java.io.File;
@@ -376,15 +376,13 @@
     @GuardedBy("sCache")
     private static final HashMap<String, PathStrategy> sCache = new HashMap<>();
 
-    @NonNull
-    private final Object mLock = new Object();
+    private final @NonNull Object mLock = new Object();
     private final int mResourceId;
     @GuardedBy("mLock")
     private String mAuthority;
     // Do NOT access directly! Use getLocalPathStrategy() instead.
     @GuardedBy("mLock")
-    @Nullable
-    private PathStrategy mLocalPathStrategy;
+    private @Nullable PathStrategy mLocalPathStrategy;
 
     public FileProvider() {
         this(ResourcesCompat.ID_NULL);
@@ -443,8 +441,7 @@
      *                                  the paths supported by the provider.
      */
     @SuppressLint("StreamFiles")
-    @NonNull
-    public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
+    public static @NonNull Uri getUriForFile(@NonNull Context context, @NonNull String authority,
             @NonNull File file, @NonNull String displayName) {
         Uri uri = getUriForFile(context, authority, file);
         return uri.buildUpon().appendQueryParameter(DISPLAYNAME_FIELD, displayName).build();
@@ -602,8 +599,7 @@
         return result;
     }
 
-    @NonNull
-    private static String removeTrailingSlash(@NonNull String path) {
+    private static @NonNull String removeTrailingSlash(@NonNull String path) {
         if (path.length() > 0 && path.charAt(path.length() - 1) == '/') {
             return path.substring(0, path.length() - 1);
         } else {
@@ -684,10 +680,10 @@
      *                      the resulting {@link Cursor}.
      * @return A {@link Cursor} containing the results of the query.
      */
-    @NonNull
     @Override
-    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
-            @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+    public @NonNull Cursor query(@NonNull Uri uri, String @Nullable [] projection,
+            @Nullable String selection, String @Nullable [] selectionArgs,
+            @Nullable String sortOrder) {
         // ContentProvider has already checked granted permissions
         final File file = getLocalPathStrategy().getFileForUri(uri);
         String displayName = uri.getQueryParameter(DISPLAYNAME_FIELD);
@@ -726,9 +722,8 @@
      * @return If the associated file has an extension, the MIME type associated with that
      * extension; otherwise <code>application/octet-stream</code>.
      */
-    @Nullable
     @Override
-    public String getType(@NonNull Uri uri) {
+    public @Nullable String getType(@NonNull Uri uri) {
         // ContentProvider has already checked granted permissions
         final File file = getLocalPathStrategy().getFileForUri(uri);
 
@@ -750,8 +745,7 @@
      */
     //@Override
     @SuppressWarnings("MissingOverride")
-    @Nullable
-    public String getTypeAnonymous(@NonNull Uri uri) {
+    public @Nullable String getTypeAnonymous(@NonNull Uri uri) {
         return "application/octet-stream";
     }
 
@@ -770,7 +764,7 @@
      */
     @Override
     public int update(@NonNull Uri uri, @NonNull ContentValues values, @Nullable String selection,
-            @Nullable String[] selectionArgs) {
+            String @Nullable [] selectionArgs) {
         throw new UnsupportedOperationException("No external updates");
     }
 
@@ -787,7 +781,7 @@
      */
     @Override
     public int delete(@NonNull Uri uri, @Nullable String selection,
-            @Nullable String[] selectionArgs) {
+            String @Nullable [] selectionArgs) {
         // ContentProvider has already checked granted permissions
         final File file = getLocalPathStrategy().getFileForUri(uri);
         return file.delete() ? 1 : 0;
@@ -820,8 +814,7 @@
     }
 
     /** Return the local {@link PathStrategy}, creating it if necessary. */
-    @NonNull
-    private PathStrategy getLocalPathStrategy() {
+    private @NonNull PathStrategy getLocalPathStrategy() {
         synchronized (mLock) {
             requireNonNull(mAuthority, "mAuthority is null. Did you override attachInfo and "
                     + "did not call super.attachInfo()?");
diff --git a/core/core/src/main/java/androidx/core/content/IntentCompat.java b/core/core/src/main/java/androidx/core/content/IntentCompat.java
index 7730a74..4691d30 100644
--- a/core/core/src/main/java/androidx/core/content/IntentCompat.java
+++ b/core/core/src/main/java/androidx/core/content/IntentCompat.java
@@ -31,10 +31,11 @@
 import android.os.Build;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.Serializable;
 import java.util.ArrayList;
 
@@ -117,8 +118,7 @@
      * @return Returns a newly created Intent that can be used to launch the
      * activity as a main application entry.
      */
-    @NonNull
-    public static Intent makeMainSelectorActivity(@NonNull String selectorAction,
+    public static @NonNull Intent makeMainSelectorActivity(@NonNull String selectorAction,
             @NonNull String selectorCategory) {
         return Intent.makeMainSelectorActivity(selectorAction, selectorCategory);
     }
@@ -161,8 +161,7 @@
      * @return Returns a newly created Intent that can be used to launch an activity where users
      * can manage unused app restrictions for a specific app.
      */
-    @NonNull
-    public static Intent createManageUnusedAppRestrictionsIntent(@NonNull Context context,
+    public static @NonNull Intent createManageUnusedAppRestrictionsIntent(@NonNull Context context,
             @NonNull String packageName) {
         if (!areUnusedAppRestrictionsAvailable(context.getPackageManager())) {
             throw new UnsupportedOperationException(
@@ -215,9 +214,8 @@
      *
      * @see Intent#putExtra(String, Parcelable)
      */
-    @Nullable
     @SuppressWarnings({"deprecation", "unchecked"})
-    public static <T> T getParcelableExtra(@NonNull Intent in, @Nullable String name,
+    public static <T> @Nullable T getParcelableExtra(@NonNull Intent in, @Nullable String name,
             @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             // Don't call this API on SDK 33 due to b/232589966.
@@ -246,11 +244,10 @@
      *
      * @see Intent#putExtra(String, Parcelable[])
      */
-    @Nullable
     @SuppressWarnings({"deprecation"})
     @SuppressLint({"ArrayReturn", "NullableCollection"})
-    public static Parcelable[] getParcelableArrayExtra(@NonNull Intent in, @Nullable String name,
-            @NonNull Class<? extends Parcelable> clazz) {
+    public static Parcelable @Nullable [] getParcelableArrayExtra(@NonNull Intent in,
+            @Nullable String name, @NonNull Class<? extends Parcelable> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             // Don't call this API on SDK 33 due to b/232589966.
             return Api33Impl.getParcelableArrayExtra(in, name, clazz);
@@ -279,10 +276,9 @@
      *
      * @see Intent#putParcelableArrayListExtra(String, ArrayList)
      */
-    @Nullable
     @SuppressWarnings({"deprecation", "unchecked"})
     @SuppressLint({"ConcreteCollection", "NullableCollection"})
-    public static <T> ArrayList<T> getParcelableArrayListExtra(
+    public static <T> @Nullable ArrayList<T> getParcelableArrayListExtra(
             @NonNull Intent in, @Nullable String name, @NonNull Class<? extends T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             // Don't call this API on SDK 33 due to b/232589966.
@@ -312,8 +308,7 @@
      * @return a Serializable value, or {@code null}
      */
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <T extends Serializable> T getSerializableExtra(@NonNull Intent in,
+    public static <T extends Serializable> @Nullable T getSerializableExtra(@NonNull Intent in,
             @Nullable String key, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             // Don't call this API on SDK 33 due to b/232589966.
diff --git a/core/core/src/main/java/androidx/core/content/IntentSanitizer.java b/core/core/src/main/java/androidx/core/content/IntentSanitizer.java
index 9297ccf..2c3071e 100644
--- a/core/core/src/main/java/androidx/core/content/IntentSanitizer.java
+++ b/core/core/src/main/java/androidx/core/content/IntentSanitizer.java
@@ -29,11 +29,12 @@
 import android.os.strictmode.UnsafeIntentLaunchViolation;
 import android.provider.MediaStore;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Consumer;
 import androidx.core.util.Predicate;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.Serializable;
 import java.util.HashMap;
 import java.util.Map;
@@ -87,8 +88,7 @@
      * @param in input intent
      * @return a copy of the input intent after filtering out unwanted members.
      */
-    @NonNull
-    public Intent sanitizeByFiltering(@NonNull Intent in) {
+    public @NonNull Intent sanitizeByFiltering(@NonNull Intent in) {
         return sanitize(in, msg -> {});
     }
 
@@ -100,8 +100,7 @@
      * @return a copy of the input intent if the input intent does not contain any unwanted members.
      * @throws SecurityException if the input intent contains any unwanted members.
      */
-    @NonNull
-    public Intent sanitizeByThrowing(@NonNull Intent in) {
+    public @NonNull Intent sanitizeByThrowing(@NonNull Intent in) {
         return sanitize(in, msg -> {
             throw new SecurityException(msg);
         });
@@ -116,8 +115,7 @@
      * @param penalty consumer of the error message if dirty members are found.
      * @return a sanitized copy of the given intent.
      */
-    @NonNull
-    public Intent sanitize(@NonNull Intent in,
+    public @NonNull Intent sanitize(@NonNull Intent in,
             @NonNull Consumer<String> penalty) {
         Intent intent = new Intent();
 
@@ -322,8 +320,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowFlags(int flags) {
+        public @NonNull Builder allowFlags(int flags) {
             mAllowedFlags |= flags;
             return this;
         }
@@ -353,8 +350,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowHistoryStackFlags() {
+        public @NonNull Builder allowHistoryStackFlags() {
             mAllowedFlags |= HISTORY_STACK_FLAGS;
             return this;
         }
@@ -372,8 +368,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowReceiverFlags() {
+        public @NonNull Builder allowReceiverFlags() {
             mAllowedFlags |= RECEIVER_FLAGS;
             return this;
         }
@@ -387,8 +382,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowAction(@NonNull String action) {
+        public @NonNull Builder allowAction(@NonNull String action) {
             checkNotNull(action);
             allowAction(action::equals);
             return this;
@@ -403,8 +397,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowAction(@NonNull Predicate<String> filter) {
+        public @NonNull Builder allowAction(@NonNull Predicate<String> filter) {
             checkNotNull(filter);
             mAllowedActions = mAllowedActions.or(filter);
             return this;
@@ -419,8 +412,7 @@
          * @return this builder
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowDataWithAuthority(@NonNull String authority) {
+        public @NonNull Builder allowDataWithAuthority(@NonNull String authority) {
             checkNotNull(authority);
             allowData(v -> authority.equals(v.getAuthority()));
             return this;
@@ -434,8 +426,7 @@
          * @param filter data filter.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowData(@NonNull Predicate<Uri> filter) {
+        public @NonNull Builder allowData(@NonNull Predicate<Uri> filter) {
             checkNotNull(filter);
             mAllowedData = mAllowedData.or(filter);
             return this;
@@ -452,8 +443,7 @@
          * @return this builder
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowType(@NonNull String type) {
+        public @NonNull Builder allowType(@NonNull String type) {
             checkNotNull(type);
             return allowType(type::equals);
         }
@@ -466,8 +456,7 @@
          * @param filter the data type filter.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowType(@NonNull Predicate<String> filter) {
+        public @NonNull Builder allowType(@NonNull Predicate<String> filter) {
             checkNotNull(filter);
             mAllowedTypes = mAllowedTypes.or(filter);
             return this;
@@ -482,8 +471,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowCategory(@NonNull String category) {
+        public @NonNull Builder allowCategory(@NonNull String category) {
             checkNotNull(category);
             return allowCategory(category::equals);
         }
@@ -497,8 +485,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowCategory(@NonNull Predicate<String> filter) {
+        public @NonNull Builder allowCategory(@NonNull Predicate<String> filter) {
             checkNotNull(filter);
             mAllowedCategories = mAllowedCategories.or(filter);
             return this;
@@ -514,8 +501,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowPackage(@NonNull String packageName) {
+        public @NonNull Builder allowPackage(@NonNull String packageName) {
             checkNotNull(packageName);
             return allowPackage(packageName::equals);
         }
@@ -531,8 +517,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowPackage(@NonNull Predicate<String> filter) {
+        public @NonNull Builder allowPackage(@NonNull Predicate<String> filter) {
             checkNotNull(filter);
             mAllowedPackages = mAllowedPackages.or(filter);
             return this;
@@ -547,8 +532,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowComponent(@NonNull ComponentName component) {
+        public @NonNull Builder allowComponent(@NonNull ComponentName component) {
             checkNotNull(component);
             return allowComponent(component::equals);
         }
@@ -562,8 +546,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowComponent(@NonNull Predicate<ComponentName> filter) {
+        public @NonNull Builder allowComponent(@NonNull Predicate<ComponentName> filter) {
             checkNotNull(filter);
             mAllowSomeComponents = true;
             mAllowedComponents = mAllowedComponents.or(filter);
@@ -579,8 +562,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowComponentWithPackage(@NonNull String packageName) {
+        public @NonNull Builder allowComponentWithPackage(@NonNull String packageName) {
             checkNotNull(packageName);
             return allowComponent(v -> packageName.equals(v.getPackageName()));
         }
@@ -595,8 +577,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowAnyComponent() {
+        public @NonNull Builder allowAnyComponent() {
             mAllowAnyComponent = true;
             mAllowedComponents = v -> true;
             return this;
@@ -609,8 +590,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowClipDataText() {
+        public @NonNull Builder allowClipDataText() {
             mAllowClipDataText = true;
             return this;
         }
@@ -624,8 +604,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowClipDataUriWithAuthority(@NonNull String authority) {
+        public @NonNull Builder allowClipDataUriWithAuthority(@NonNull String authority) {
             checkNotNull(authority);
             return allowClipDataUri(v -> authority.equals(v.getAuthority()));
         }
@@ -640,8 +619,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowClipDataUri(@NonNull Predicate<Uri> filter) {
+        public @NonNull Builder allowClipDataUri(@NonNull Predicate<Uri> filter) {
             checkNotNull(filter);
             mAllowedClipDataUri = mAllowedClipDataUri.or(filter);
             return this;
@@ -657,8 +635,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowClipData(@NonNull Predicate<ClipData> filter) {
+        public @NonNull Builder allowClipData(@NonNull Predicate<ClipData> filter) {
             checkNotNull(filter);
             mAllowedClipData = mAllowedClipData.or(filter);
             return this;
@@ -674,8 +651,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtra(@NonNull String key, @NonNull Class<?> clazz) {
+        public @NonNull Builder allowExtra(@NonNull String key, @NonNull Class<?> clazz) {
             return allowExtra(key, clazz, (v) -> true);
         }
 
@@ -692,8 +668,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public <T> Builder allowExtra(@NonNull String key, @NonNull Class<T> clazz,
+        public <T> @NonNull Builder allowExtra(@NonNull String key, @NonNull Class<T> clazz,
                 @NonNull Predicate<T> valueFilter) {
             checkNotNull(key);
             checkNotNull(clazz);
@@ -712,8 +687,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtra(@NonNull String key, @NonNull Predicate<Object> filter) {
+        public @NonNull Builder allowExtra(@NonNull String key, @NonNull Predicate<Object> filter) {
             checkNotNull(key);
             checkNotNull(filter);
             Predicate<Object> allowedExtra = mAllowedExtras.get(key);
@@ -736,8 +710,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtraStreamUriWithAuthority(@NonNull String uriAuthority) {
+        public @NonNull Builder allowExtraStreamUriWithAuthority(@NonNull String uriAuthority) {
             checkNotNull(uriAuthority);
             allowExtra(Intent.EXTRA_STREAM, Uri.class,
                     (v) -> uriAuthority.equals(v.getAuthority()));
@@ -757,8 +730,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtraStream(@NonNull Predicate<Uri> filter) {
+        public @NonNull Builder allowExtraStream(@NonNull Predicate<Uri> filter) {
             allowExtra(Intent.EXTRA_STREAM, Uri.class, filter);
             return this;
         }
@@ -777,8 +749,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtraOutput(@NonNull String uriAuthority) {
+        public @NonNull Builder allowExtraOutput(@NonNull String uriAuthority) {
             allowExtra(MediaStore.EXTRA_OUTPUT, Uri.class,
                     (v) -> uriAuthority.equals(v.getAuthority()));
             return this;
@@ -798,8 +769,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowExtraOutput(@NonNull Predicate<Uri> filter) {
+        public @NonNull Builder allowExtraOutput(@NonNull Predicate<Uri> filter) {
             allowExtra(MediaStore.EXTRA_OUTPUT, Uri.class, filter);
             return this;
         }
@@ -810,8 +780,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowIdentifier() {
+        public @NonNull Builder allowIdentifier() {
             mAllowIdentifier = true;
             return this;
         }
@@ -822,8 +791,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowSelector() {
+        public @NonNull Builder allowSelector() {
             mAllowSelector = true;
             return this;
         }
@@ -834,8 +802,7 @@
          * @return this builder.
          */
         @SuppressLint("BuilderSetStyle")
-        @NonNull
-        public Builder allowSourceBounds() {
+        public @NonNull Builder allowSourceBounds() {
             mAllowSourceBounds = true;
             return this;
         }
@@ -845,8 +812,7 @@
          *
          * @return the IntentSanitizer
          */
-        @NonNull
-        public IntentSanitizer build() {
+        public @NonNull IntentSanitizer build() {
             if ((mAllowAnyComponent && mAllowSomeComponents)
                     || (!mAllowAnyComponent && !mAllowSomeComponents)) {
                 throw new SecurityException(
diff --git a/core/core/src/main/java/androidx/core/content/LocusIdCompat.java b/core/core/src/main/java/androidx/core/content/LocusIdCompat.java
index 3349721..fb76614 100644
--- a/core/core/src/main/java/androidx/core/content/LocusIdCompat.java
+++ b/core/core/src/main/java/androidx/core/content/LocusIdCompat.java
@@ -20,11 +20,12 @@
 import android.content.LocusId;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An identifier for an unique state (locus) in the application. Should be stable across reboots and
  * backup / restore.
@@ -89,8 +90,7 @@
     /**
      * Gets the canonical {@code id} associated with the locus.
      */
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
@@ -103,7 +103,7 @@
     }
 
     @Override
-    public boolean equals(@Nullable final Object obj) {
+    public boolean equals(final @Nullable Object obj) {
         if (this == obj) return true;
         if (obj == null) return false;
         if (getClass() != obj.getClass()) return false;
@@ -115,34 +115,30 @@
         }
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return "LocusIdCompat[" + getSanitizedId() + "]";
     }
 
     /**
      * @return {@link LocusId} object from this compat object.
      */
-    @NonNull
     @RequiresApi(29)
-    public LocusId toLocusId() {
+    public @NonNull LocusId toLocusId() {
         return mWrapped;
     }
 
     /**
      * Returns an instance of LocusIdCompat from given {@link LocusId}.
      */
-    @NonNull
     @RequiresApi(29)
-    public static LocusIdCompat toLocusIdCompat(@NonNull final LocusId locusId) {
+    public static @NonNull LocusIdCompat toLocusIdCompat(final @NonNull LocusId locusId) {
         Preconditions.checkNotNull(locusId, "locusId cannot be null");
         return new LocusIdCompat(Preconditions.checkStringNotEmpty(Api29Impl.getId(locusId),
                 "id cannot be empty"));
     }
 
-    @NonNull
-    private String getSanitizedId() {
+    private @NonNull String getSanitizedId() {
         final int size = mId.length();
         return size + "_chars";
     }
@@ -154,16 +150,14 @@
         /**
          * @return {@link LocusId} object from this compat object.
          */
-        @NonNull
-        static LocusId create(@NonNull final String id) {
+        static @NonNull LocusId create(final @NonNull String id) {
             return new LocusId(id);
         }
 
         /**
          * @return {@code id} from the LocusId object.
          */
-        @NonNull
-        static String getId(@NonNull final LocusId obj) {
+        static @NonNull String getId(final @NonNull LocusId obj) {
             return obj.getId();
         }
     }
diff --git a/core/core/src/main/java/androidx/core/content/MimeTypeFilter.java b/core/core/src/main/java/androidx/core/content/MimeTypeFilter.java
index 7075f87..0ad3062 100644
--- a/core/core/src/main/java/androidx/core/content/MimeTypeFilter.java
+++ b/core/core/src/main/java/androidx/core/content/MimeTypeFilter.java
@@ -16,8 +16,8 @@
 
 package androidx.core.content;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 
@@ -44,7 +44,7 @@
     }
 
     private static boolean mimeTypeAgainstFilter(
-            @NonNull String[] mimeTypeParts, @NonNull String[] filterParts) {
+            String @NonNull [] mimeTypeParts, String @NonNull [] filterParts) {
         if (filterParts.length != 2) {
             throw new IllegalArgumentException(
                     "Ill-formatted MIME type filter. Must be type/subtype.");
@@ -87,9 +87,8 @@
      * Matches one nullable MIME type against an array of MIME type filters.
      * @return The first matching filter, or null if nothing matches.
      */
-    @Nullable
-    public static String matches(
-            @Nullable String mimeType, @NonNull String[] filters) {
+    public static @Nullable String matches(
+            @Nullable String mimeType, String @NonNull [] filters) {
         if (mimeType == null) {
             return null;
         }
@@ -109,9 +108,8 @@
      * Matches multiple MIME types against an array of MIME type filters.
      * @return The first matching MIME type, or null if nothing matches.
      */
-    @Nullable
-    public static String matches(
-            @Nullable String[] mimeTypes, @NonNull String filter) {
+    public static @Nullable String matches(
+            String @Nullable [] mimeTypes, @NonNull String filter) {
         if (mimeTypes == null) {
             return null;
         }
@@ -131,9 +129,8 @@
      * Matches multiple MIME types against an array of MIME type filters.
      * @return The list of matching MIME types, or empty array if nothing matches.
      */
-    @NonNull
-    public static String[] matchesMany(
-            @Nullable String[] mimeTypes, @NonNull String filter) {
+    public static String @NonNull [] matchesMany(
+            String @Nullable [] mimeTypes, @NonNull String filter) {
         if (mimeTypes == null) {
             return new String[] {};
         }
diff --git a/core/core/src/main/java/androidx/core/content/PackageManagerCompat.java b/core/core/src/main/java/androidx/core/content/PackageManagerCompat.java
index 6bee967..7b95c6f 100644
--- a/core/core/src/main/java/androidx/core/content/PackageManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/content/PackageManagerCompat.java
@@ -36,8 +36,6 @@
 import android.util.Log;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.concurrent.futures.ResolvableFuture;
@@ -45,6 +43,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.util.List;
 import java.util.concurrent.Executors;
@@ -129,8 +130,7 @@
      * not exist yet.
      * </ul>
      */
-    @NonNull
-    public static ListenableFuture<Integer> getUnusedAppRestrictionsStatus(
+    public static @NonNull ListenableFuture<Integer> getUnusedAppRestrictionsStatus(
             @NonNull Context context) {
         ResolvableFuture<Integer> resultFuture = ResolvableFuture.create();
         // If the user is in locked direct boot mode, return error as we cannot access the
@@ -211,10 +211,9 @@
      * Verifiers exist, this method will return the first Verifier's package name.
      *
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @SuppressWarnings("deprecation")
-    public static String getPermissionRevocationVerifierApp(
+    public static @Nullable String getPermissionRevocationVerifierApp(
             @NonNull PackageManager packageManager) {
         Intent permissionRevocationSettingsIntent =
                 new Intent(ACTION_PERMISSION_REVOCATION_SETTINGS)
diff --git a/core/core/src/main/java/androidx/core/content/PermissionChecker.java b/core/core/src/main/java/androidx/core/content/PermissionChecker.java
index 12bec8f..459ac9a 100644
--- a/core/core/src/main/java/androidx/core/content/PermissionChecker.java
+++ b/core/core/src/main/java/androidx/core/content/PermissionChecker.java
@@ -24,12 +24,13 @@
 import android.os.Process;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.app.AppOpsManagerCompat;
 import androidx.core.util.ObjectsCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/content/SharedPreferencesCompat.java b/core/core/src/main/java/androidx/core/content/SharedPreferencesCompat.java
index d780f6f..c8db841 100644
--- a/core/core/src/main/java/androidx/core/content/SharedPreferencesCompat.java
+++ b/core/core/src/main/java/androidx/core/content/SharedPreferencesCompat.java
@@ -18,7 +18,7 @@
 
 import android.content.SharedPreferences;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * @deprecated This compatibility class is no longer required. Use {@link SharedPreferences}
@@ -40,7 +40,7 @@
             Helper() {
             }
 
-            public void apply(@NonNull SharedPreferences.Editor editor) {
+            public void apply(SharedPreferences.@NonNull Editor editor) {
                 try {
                     editor.apply();
                 } catch (AbstractMethodError unused) {
@@ -73,7 +73,7 @@
          * {@link SharedPreferences.Editor#apply()} directly.
          */
         @Deprecated
-        public void apply(@NonNull SharedPreferences.Editor editor) {
+        public void apply(SharedPreferences.@NonNull Editor editor) {
             // Note that this redirection is needed to not break the public API chain
             // of getInstance().apply() calls. Otherwise this method could (and should)
             // be static.
diff --git a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportCallback.java b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportCallback.java
index 755717e..731fe80 100644
--- a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportCallback.java
+++ b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportCallback.java
@@ -20,10 +20,11 @@
 
 import android.os.RemoteException;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Wrapper class for {IUnusedAppRestrictionsBackportCallback}.
  *
diff --git a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportService.java b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportService.java
index 8bf7c83..91d9a2f 100644
--- a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportService.java
+++ b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportService.java
@@ -22,11 +22,12 @@
 import android.os.IBinder;
 import android.os.RemoteException;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback;
 import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportService;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Wrapper class for {@link IUnusedAppRestrictionsBackportService}.
  *
@@ -64,9 +65,8 @@
         }
     };
 
-    @Nullable
     @Override
-    public IBinder onBind(@Nullable Intent intent) {
+    public @Nullable IBinder onBind(@Nullable Intent intent) {
         return mBinder;
     }
 
diff --git a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportServiceConnection.java b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportServiceConnection.java
index b58feab..b211856 100644
--- a/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportServiceConnection.java
+++ b/core/core/src/main/java/androidx/core/content/UnusedAppRestrictionsBackportServiceConnection.java
@@ -30,13 +30,14 @@
 import android.os.RemoteException;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 import androidx.concurrent.futures.ResolvableFuture;
 import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback;
 import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportService;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * {@link ServiceConnection} to use while binding to a
  * {@link IUnusedAppRestrictionsBackportService}.
diff --git a/core/core/src/main/java/androidx/core/content/UriMatcherCompat.java b/core/core/src/main/java/androidx/core/content/UriMatcherCompat.java
index e3b33c7..50a5e7d 100644
--- a/core/core/src/main/java/androidx/core/content/UriMatcherCompat.java
+++ b/core/core/src/main/java/androidx/core/content/UriMatcherCompat.java
@@ -19,9 +19,10 @@
 import android.content.UriMatcher;
 import android.net.Uri;
 
-import androidx.annotation.NonNull;
 import androidx.core.util.Predicate;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing {@link UriMatcher} to create Uri Predicate.
  */
@@ -35,8 +36,7 @@
      * @param matcher the uriMatcher.
      * @return the predicate created from the uriMatcher.
      */
-    @NonNull
-    public static Predicate<Uri> asPredicate(@NonNull UriMatcher matcher) {
+    public static @NonNull Predicate<Uri> asPredicate(@NonNull UriMatcher matcher) {
         return v -> matcher.match(v) != UriMatcher.NO_MATCH;
     }
 }
diff --git a/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java b/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
index be98811..b3743c7 100644
--- a/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
@@ -23,11 +23,12 @@
 import android.content.pm.SigningInfo;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.Size;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
@@ -79,9 +80,8 @@
      * @throws PackageManager.NameNotFoundException if the package cannot be found through the
      *                                              provided {@param packageManager}
      */
-    @NonNull
     @SuppressWarnings("deprecation")
-    public static List<Signature> getSignatures(@NonNull PackageManager packageManager,
+    public static @NonNull List<Signature> getSignatures(@NonNull PackageManager packageManager,
             @NonNull String packageName) throws PackageManager.NameNotFoundException {
         Signature[] array;
         if (Build.VERSION.SDK_INT >= 28) {
@@ -241,7 +241,7 @@
         return false;
     }
 
-    private static boolean byteArrayContains(@NonNull byte[][] array, @NonNull byte[] expected) {
+    private static boolean byteArrayContains(byte @NonNull [][] array, byte @NonNull [] expected) {
         for (byte[] item : array) {
             if (Arrays.equals(expected, item)) {
                 return true;
@@ -269,7 +269,7 @@
         }
 
         static boolean hasSigningCertificate(@NonNull PackageManager packageManager,
-                @NonNull String packageName, @NonNull byte[] bytes, int type) {
+                @NonNull String packageName, byte @NonNull [] bytes, int type) {
             return packageManager.hasSigningCertificate(packageName, bytes, type);
         }
 
@@ -277,13 +277,12 @@
             return signingInfo.hasMultipleSigners();
         }
 
-        @Nullable
-        static Signature[] getApkContentsSigners(@NonNull SigningInfo signingInfo) {
+        static Signature @Nullable [] getApkContentsSigners(@NonNull SigningInfo signingInfo) {
             return signingInfo.getApkContentsSigners();
         }
 
-        @Nullable
-        static Signature[] getSigningCertificateHistory(@NonNull SigningInfo signingInfo) {
+        static Signature @Nullable [] getSigningCertificateHistory(
+                @NonNull SigningInfo signingInfo) {
             return signingInfo.getSigningCertificateHistory();
         }
 
diff --git a/core/core/src/main/java/androidx/core/content/pm/PermissionInfoCompat.java b/core/core/src/main/java/androidx/core/content/pm/PermissionInfoCompat.java
index 8cf8f7e..591d427 100644
--- a/core/core/src/main/java/androidx/core/content/pm/PermissionInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/PermissionInfoCompat.java
@@ -21,10 +21,11 @@
 import android.os.Build;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java
index 06bab24..39bc7b9 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoChangeListener.java
@@ -19,9 +19,10 @@
 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
 
 import androidx.annotation.AnyThread;
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 
 /**
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
index 271eee3..30cf54a 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutInfoCompat.java
@@ -33,8 +33,6 @@
 import android.text.TextUtils;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
@@ -45,6 +43,9 @@
 import androidx.core.net.UriCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -93,8 +94,7 @@
     Person[] mPersons;
     Set<String> mCategories;
 
-    @Nullable
-    LocusIdCompat mLocusId;
+    @Nullable LocusIdCompat mLocusId;
     // TODO: Support |auto| when the value of mIsLongLived is not set
     boolean mIsLongLived;
 
@@ -221,16 +221,14 @@
      * devices so that shortcuts will still be valid when restored on a different device.
      * See {@link android.content.pm.ShortcutManager} for details.
      */
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
     /**
      * Return the package name of the publisher app.
      */
-    @NonNull
-    public String getPackage() {
+    public @NonNull String getPackage() {
         return mPackageName;
     }
 
@@ -243,8 +241,7 @@
      *
      * @see Builder#setActivity(ComponentName)
      */
-    @Nullable
-    public ComponentName getActivity() {
+    public @Nullable ComponentName getActivity() {
         return mActivity;
     }
 
@@ -253,8 +250,7 @@
      *
      * @see Builder#setShortLabel(CharSequence)
      */
-    @NonNull
-    public CharSequence getShortLabel() {
+    public @NonNull CharSequence getShortLabel() {
         return mLabel;
     }
 
@@ -263,8 +259,7 @@
      *
      * @see Builder#setLongLabel(CharSequence)
      */
-    @Nullable
-    public CharSequence getLongLabel() {
+    public @Nullable CharSequence getLongLabel() {
         return mLongLabel;
     }
 
@@ -274,8 +269,7 @@
      *
      * @see Builder#setDisabledMessage(CharSequence)
      */
-    @Nullable
-    public CharSequence getDisabledMessage() {
+    public @Nullable CharSequence getDisabledMessage() {
         return mDisabledMessage;
     }
 
@@ -292,8 +286,7 @@
      *
      * @see Builder#setIntent(Intent)
      */
-    @NonNull
-    public Intent getIntent() {
+    public @NonNull Intent getIntent() {
         return mIntents[mIntents.length - 1];
     }
 
@@ -302,8 +295,7 @@
      *
      * @see Builder#setIntents(Intent[])
      */
-    @NonNull
-    public Intent[] getIntents() {
+    public Intent @NonNull [] getIntents() {
         return Arrays.copyOf(mIntents, mIntents.length);
     }
 
@@ -312,8 +304,7 @@
      *
      * @see Builder#setCategories(Set)
      */
-    @Nullable
-    public Set<String> getCategories() {
+    public @Nullable Set<String> getCategories() {
         return mCategories;
     }
 
@@ -324,8 +315,7 @@
      * {@link androidx.core.app.NotificationCompat} and
      * {@link android.view.contentcapture.ContentCaptureContext}) that are correlated.
      */
-    @Nullable
-    public LocusIdCompat getLocusId() {
+    public @Nullable LocusIdCompat getLocusId() {
         return mLocusId;
     }
 
@@ -350,8 +340,7 @@
     @RequiresApi(25)
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @VisibleForTesting
-    @Nullable
-    static Person[] getPersonsFromExtra(@NonNull PersistableBundle bundle) {
+    static Person @Nullable [] getPersonsFromExtra(@NonNull PersistableBundle bundle) {
         if (bundle == null || !bundle.containsKey(EXTRA_PERSON_COUNT)) {
             return null;
         }
@@ -381,8 +370,8 @@
      */
     @RequiresApi(25)
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    static List<ShortcutInfoCompat> fromShortcuts(@NonNull final Context context,
-            @NonNull final List<ShortcutInfo> shortcuts) {
+    static List<ShortcutInfoCompat> fromShortcuts(final @NonNull Context context,
+            final @NonNull List<ShortcutInfo> shortcuts) {
         final List<ShortcutInfoCompat> results = new ArrayList<>(shortcuts.size());
         for (ShortcutInfo s : shortcuts) {
             results.add(new ShortcutInfoCompat.Builder(context, s).build());
@@ -390,8 +379,7 @@
         return results;
     }
 
-    @Nullable
-    public PersistableBundle getExtras() {
+    public @Nullable PersistableBundle getExtras() {
         return mExtras;
     }
 
@@ -400,16 +388,14 @@
      * shortcut is published.
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public Bundle getTransientExtras() {
+    public @Nullable Bundle getTransientExtras() {
         return mTransientExtras;
     }
 
     /**
      * {@link UserHandle} on which the publisher created this shortcut.
      */
-    @Nullable
-    public UserHandle getUserHandle() {
+    public @Nullable UserHandle getUserHandle() {
         return mUser;
     }
 
@@ -494,8 +480,7 @@
     }
 
     @RequiresApi(25)
-    @Nullable
-    static LocusIdCompat getLocusId(@NonNull final ShortcutInfo shortcutInfo) {
+    static @Nullable LocusIdCompat getLocusId(final @NonNull ShortcutInfo shortcutInfo) {
         if (Build.VERSION.SDK_INT >= 29) {
             if (shortcutInfo.getLocusId() == null) return null;
             return LocusIdCompat.toLocusIdCompat(shortcutInfo.getLocusId());
@@ -525,8 +510,7 @@
      */
     @RequiresApi(25)
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    private static LocusIdCompat getLocusIdFromExtra(@Nullable PersistableBundle bundle) {
+    private static @Nullable LocusIdCompat getLocusIdFromExtra(@Nullable PersistableBundle bundle) {
         if (bundle == null) return null;
         final String locusId = bundle.getString(EXTRA_LOCUS_ID);
         return locusId == null ? null : new LocusIdCompat(locusId);
@@ -638,8 +622,7 @@
          *
          * <p>The recommended maximum length is 10 characters.
          */
-        @NonNull
-        public Builder setShortLabel(@NonNull CharSequence shortLabel) {
+        public @NonNull Builder setShortLabel(@NonNull CharSequence shortLabel) {
             mInfo.mLabel = shortLabel;
             return this;
         }
@@ -652,8 +635,7 @@
          *
          * <p>The recommend maximum length is 25 characters.
          */
-        @NonNull
-        public Builder setLongLabel(@NonNull CharSequence longLabel) {
+        public @NonNull Builder setLongLabel(@NonNull CharSequence longLabel) {
             mInfo.mLongLabel = longLabel;
             return this;
         }
@@ -664,8 +646,7 @@
          *
          * @see ShortcutInfo#getDisabledMessage()
          */
-        @NonNull
-        public Builder setDisabledMessage(@NonNull CharSequence disabledMessage) {
+        public @NonNull Builder setDisabledMessage(@NonNull CharSequence disabledMessage) {
             mInfo.mDisabledMessage = disabledMessage;
             return this;
         }
@@ -679,8 +660,7 @@
          * <p>The given {@code intent} can contain extras, but these extras must contain values
          * of primitive types in order for the system to persist these values.
          */
-        @NonNull
-        public Builder setIntent(@NonNull Intent intent) {
+        public @NonNull Builder setIntent(@NonNull Intent intent) {
             return setIntents(new Intent[]{intent});
         }
 
@@ -690,8 +670,7 @@
          * intents. The last element in the list represents the only intent that doesn't place
          * an activity on the back stack.
          */
-        @NonNull
-        public Builder setIntents(@NonNull Intent[] intents) {
+        public @NonNull Builder setIntents(Intent @NonNull [] intents) {
             mInfo.mIntents = intents;
             return this;
         }
@@ -699,8 +678,7 @@
         /**
          * Sets an icon of a shortcut.
          */
-        @NonNull
-        public Builder setIcon(IconCompat icon) {
+        public @NonNull Builder setIcon(IconCompat icon) {
             mInfo.mIcon = icon;
             return this;
         }
@@ -713,8 +691,7 @@
          * {@link android.view.contentcapture.ContentCaptureContext}) so the device's intelligence
          * services can correlate them.
          */
-        @NonNull
-        public Builder setLocusId(@Nullable final LocusIdCompat locusId) {
+        public @NonNull Builder setLocusId(final @Nullable LocusIdCompat locusId) {
             mInfo.mLocusId = locusId;
             return this;
         }
@@ -729,8 +706,7 @@
          * Additionally, the shortcut will be long-lived.
          * @see #setLongLived(boolean)
          */
-        @NonNull
-        public Builder setIsConversation() {
+        public @NonNull Builder setIsConversation() {
             mIsConversation = true;
             return this;
         }
@@ -742,8 +718,7 @@
          * @see ShortcutInfo#getActivity()
          * @see ShortcutInfo.Builder#setActivity(ComponentName)
          */
-        @NonNull
-        public Builder setActivity(@NonNull ComponentName activity) {
+        public @NonNull Builder setActivity(@NonNull ComponentName activity) {
             mInfo.mActivity = activity;
             return this;
         }
@@ -760,8 +735,7 @@
          *
          * @see #setActivity(ComponentName)
          */
-        @NonNull
-        public Builder setAlwaysBadged() {
+        public @NonNull Builder setAlwaysBadged() {
             mInfo.mIsAlwaysBadged = true;
             return this;
         }
@@ -774,16 +748,14 @@
          *
          * @see Person
          */
-        @NonNull
-        public Builder setPerson(@NonNull Person person) {
+        public @NonNull Builder setPerson(@NonNull Person person) {
             return setPersons(new Person[]{person});
         }
 
         /**
          * Sets multiple persons instead of a single person.
          */
-        @NonNull
-        public Builder setPersons(@NonNull Person[] persons) {
+        public @NonNull Builder setPersons(Person @NonNull [] persons) {
             mInfo.mPersons = persons;
             return this;
         }
@@ -799,8 +771,7 @@
          *
          * @see ShortcutInfo#getCategories()
          */
-        @NonNull
-        public Builder setCategories(@NonNull Set<String> categories) {
+        public @NonNull Builder setCategories(@NonNull Set<String> categories) {
             ArraySet<String> set = new ArraySet<>();
             set.addAll(categories);
             mInfo.mCategories = set;
@@ -811,8 +782,7 @@
          * @deprecated Use {@ink #setLongLived(boolean)) instead.
          */
         @Deprecated
-        @NonNull
-        public Builder setLongLived() {
+        public @NonNull Builder setLongLived() {
             mInfo.mIsLongLived = true;
             return this;
         }
@@ -822,8 +792,7 @@
          * (as a dynamic or pinned shortcut). If it is long lived, it can be cached by various
          * system services even after it has been unpublished as a dynamic shortcut.
          */
-        @NonNull
-        public Builder setLongLived(boolean longLived) {
+        public @NonNull Builder setLongLived(boolean longLived) {
             mInfo.mIsLongLived = longLived;
             return this;
         }
@@ -842,8 +811,7 @@
          * actually sent to {@link ShortcutManager}. These shortcuts might still be made
          * available to other surfaces via alternative means.
          */
-        @NonNull
-        public Builder setExcludedFromSurfaces(final int surfaces) {
+        public @NonNull Builder setExcludedFromSurfaces(final int surfaces) {
             mInfo.mExcludedSurfaces = surfaces;
             return this;
         }
@@ -854,8 +822,7 @@
          *
          * @see ShortcutInfo#getRank() for details.
          */
-        @NonNull
-        public Builder setRank(int rank) {
+        public @NonNull Builder setRank(int rank) {
             mInfo.mRank = rank;
             return this;
         }
@@ -868,8 +835,7 @@
          *
          * @see ShortcutInfo#getExtras
          */
-        @NonNull
-        public Builder setExtras(@NonNull PersistableBundle extras) {
+        public @NonNull Builder setExtras(@NonNull PersistableBundle extras) {
             mInfo.mExtras = extras;
             return this;
         }
@@ -877,8 +843,7 @@
         /**
          */
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        @NonNull
-        public Builder setTransientExtras(@NonNull final Bundle transientExtras) {
+        public @NonNull Builder setTransientExtras(final @NonNull Bundle transientExtras) {
             mInfo.mTransientExtras = Preconditions.checkNotNull(transientExtras);
             return this;
         }
@@ -894,8 +859,7 @@
          *                   .START_EXERCISE.
          */
         @SuppressLint("MissingGetterMatchingBuilder")
-        @NonNull
-        public Builder addCapabilityBinding(@NonNull String capability) {
+        public @NonNull Builder addCapabilityBinding(@NonNull String capability) {
             if (mCapabilityBindings == null) {
                 mCapabilityBindings = new HashSet<>();
             }
@@ -919,8 +883,7 @@
          *                        the shortcut.
          */
         @SuppressLint("MissingGetterMatchingBuilder")
-        @NonNull
-        public Builder addCapabilityBinding(@NonNull String capability,
+        public @NonNull Builder addCapabilityBinding(@NonNull String capability,
                 @NonNull String parameter, @NonNull List<String> parameterValues) {
             addCapabilityBinding(capability);
 
@@ -942,8 +905,7 @@
          * slice, instead of an intent.
          */
         @SuppressLint("MissingGetterMatchingBuilder")
-        @NonNull
-        public Builder setSliceUri(@NonNull Uri sliceUri) {
+        public @NonNull Builder setSliceUri(@NonNull Uri sliceUri) {
             mSliceUri = sliceUri;
             return this;
         }
@@ -951,8 +913,7 @@
         /**
          * Creates a {@link ShortcutInfoCompat} instance.
          */
-        @NonNull
-        public ShortcutInfoCompat build() {
+        public @NonNull ShortcutInfoCompat build() {
             // Verify the arguments
             if (TextUtils.isEmpty(mInfo.mLabel)) {
                 throw new IllegalArgumentException("Shortcut must have a non-empty label");
@@ -1007,7 +968,7 @@
 
     @RequiresApi(33)
     private static class Api33Impl {
-        static void setExcludedFromSurfaces(@NonNull final ShortcutInfo.Builder builder,
+        static void setExcludedFromSurfaces(final ShortcutInfo.@NonNull Builder builder,
                 final int surfaces) {
             builder.setExcludedFromSurfaces(surfaces);
         }
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
index 03e4741..c9c2a65 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutManagerCompat.java
@@ -38,8 +38,6 @@
 import android.util.DisplayMetrics;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.RestrictTo.Scope;
@@ -48,6 +46,9 @@
 import androidx.core.graphics.drawable.IconCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.InputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -186,8 +187,8 @@
      * @see IntentSender
      * @see android.app.PendingIntent#getIntentSender()
      */
-    public static boolean requestPinShortcut(@NonNull final Context context,
-            @NonNull ShortcutInfoCompat shortcut, @Nullable final IntentSender callback) {
+    public static boolean requestPinShortcut(final @NonNull Context context,
+            @NonNull ShortcutInfoCompat shortcut, final @Nullable IntentSender callback) {
         if (Build.VERSION.SDK_INT <= 32
                 && shortcut.isExcludedFromSurfaces(ShortcutInfoCompat.SURFACE_LAUNCHER)) {
             // A shortcut that is not frequently used cannot be pinned to WorkSpace.
@@ -234,8 +235,7 @@
      *
      * @see Intent#ACTION_CREATE_SHORTCUT
      */
-    @NonNull
-    public static Intent createShortcutResultIntent(@NonNull Context context,
+    public static @NonNull Intent createShortcutResultIntent(@NonNull Context context,
             @NonNull ShortcutInfoCompat shortcut) {
         Intent result = null;
         if (Build.VERSION.SDK_INT >= 26) {
@@ -276,8 +276,7 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    @NonNull
-    public static List<ShortcutInfoCompat> getShortcuts(@NonNull final Context context,
+    public static @NonNull List<ShortcutInfoCompat> getShortcuts(final @NonNull Context context,
             @ShortcutMatchFlags int matchFlags) {
         if (Build.VERSION.SDK_INT >= 30) {
             final List<ShortcutInfo> shortcuts =
@@ -367,7 +366,7 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static boolean isRateLimitingActive(@NonNull final Context context) {
+    public static boolean isRateLimitingActive(final @NonNull Context context) {
         Preconditions.checkNotNull(context);
         if (Build.VERSION.SDK_INT >= 25) {
             return context.getSystemService(ShortcutManager.class).isRateLimitingActive();
@@ -387,7 +386,7 @@
      * 1 + 2 * {@link android.graphics.drawable.AdaptiveIconDrawable#getExtraInsetFraction()} to
      * the returned size.
      */
-    public static int getIconMaxWidth(@NonNull final Context context) {
+    public static int getIconMaxWidth(final @NonNull Context context) {
         Preconditions.checkNotNull(context);
         if (Build.VERSION.SDK_INT >= 25) {
             return context.getSystemService(ShortcutManager.class).getIconMaxWidth();
@@ -398,7 +397,7 @@
     /**
      * Return the max height for icons, in pixels.
      */
-    public static int getIconMaxHeight(@NonNull final Context context) {
+    public static int getIconMaxHeight(final @NonNull Context context) {
         Preconditions.checkNotNull(context);
         if (Build.VERSION.SDK_INT >= 25) {
             return context.getSystemService(ShortcutManager.class).getIconMaxHeight();
@@ -423,8 +422,8 @@
      * <p>This method is not supported on devices running SDK < 25 since the platform class will
      * not be available.
      */
-    public static void reportShortcutUsed(@NonNull final Context context,
-            @NonNull final String shortcutId) {
+    public static void reportShortcutUsed(final @NonNull Context context,
+            final @NonNull String shortcutId) {
         Preconditions.checkNotNull(context);
         Preconditions.checkNotNull(shortcutId);
         if (Build.VERSION.SDK_INT >= 25) {
@@ -460,8 +459,8 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static boolean setDynamicShortcuts(@NonNull final Context context,
-            @NonNull final List<ShortcutInfoCompat> shortcutInfoList) {
+    public static boolean setDynamicShortcuts(final @NonNull Context context,
+            final @NonNull List<ShortcutInfoCompat> shortcutInfoList) {
         Preconditions.checkNotNull(context);
         Preconditions.checkNotNull(shortcutInfoList);
         final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface(
@@ -492,8 +491,7 @@
      * Re-publishing returned {@link ShortcutInfo}s via APIs such as
      * {@link #addDynamicShortcuts(Context, List)} may cause loss of information such as icons.
      */
-    @NonNull
-    public static List<ShortcutInfoCompat> getDynamicShortcuts(@NonNull Context context) {
+    public static @NonNull List<ShortcutInfoCompat> getDynamicShortcuts(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 25) {
             List<ShortcutInfo> shortcuts = context.getSystemService(
                     ShortcutManager.class).getDynamicShortcuts();
@@ -552,8 +550,8 @@
     }
 
     @VisibleForTesting
-    static boolean convertUriIconToBitmapIcon(@NonNull final Context context,
-            @NonNull final ShortcutInfoCompat info) {
+    static boolean convertUriIconToBitmapIcon(final @NonNull Context context,
+            final @NonNull ShortcutInfoCompat info) {
         if (info.mIcon == null) {
             return false;
         }
@@ -576,8 +574,8 @@
     }
 
     @VisibleForTesting
-    static void convertUriIconsToBitmapIcons(@NonNull final Context context,
-            @NonNull final List<ShortcutInfoCompat> shortcutInfoList) {
+    static void convertUriIconsToBitmapIcons(final @NonNull Context context,
+            final @NonNull List<ShortcutInfoCompat> shortcutInfoList) {
         final List<ShortcutInfoCompat> shortcuts = new ArrayList<>(shortcutInfoList);
         for (ShortcutInfoCompat info : shortcuts) {
             if (!convertUriIconToBitmapIcon(context, info)) {
@@ -603,8 +601,8 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static void disableShortcuts(@NonNull final Context context,
-            @NonNull final List<String> shortcutIds, @Nullable final CharSequence disabledMessage) {
+    public static void disableShortcuts(final @NonNull Context context,
+            final @NonNull List<String> shortcutIds, final @Nullable CharSequence disabledMessage) {
         if (Build.VERSION.SDK_INT >= 25) {
             context.getSystemService(ShortcutManager.class)
                     .disableShortcuts(shortcutIds, disabledMessage);
@@ -632,8 +630,8 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static void enableShortcuts(@NonNull final Context context,
-            @NonNull final List<ShortcutInfoCompat> shortcutInfoList) {
+    public static void enableShortcuts(final @NonNull Context context,
+            final @NonNull List<ShortcutInfoCompat> shortcutInfoList) {
         final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface(
                 shortcutInfoList, ShortcutInfoCompat.SURFACE_LAUNCHER);
         if (Build.VERSION.SDK_INT >= 25) {
@@ -698,8 +696,8 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static void removeLongLivedShortcuts(@NonNull final Context context,
-            @NonNull final List<String> shortcutIds) {
+    public static void removeLongLivedShortcuts(final @NonNull Context context,
+            final @NonNull List<String> shortcutIds) {
         if (Build.VERSION.SDK_INT < 30) {
             removeDynamicShortcuts(context, shortcutIds);
             return;
@@ -745,8 +743,8 @@
      *
      * @throws IllegalStateException when the user is locked.
      */
-    public static boolean pushDynamicShortcut(@NonNull final Context context,
-            @NonNull final ShortcutInfoCompat shortcut) {
+    public static boolean pushDynamicShortcut(final @NonNull Context context,
+            final @NonNull ShortcutInfoCompat shortcut) {
         Preconditions.checkNotNull(context);
         Preconditions.checkNotNull(shortcut);
 
@@ -800,7 +798,7 @@
     }
 
     private static String getShortcutInfoCompatWithLowestRank(
-            @NonNull final List<ShortcutInfoCompat> shortcuts) {
+            final @NonNull List<ShortcutInfoCompat> shortcuts) {
         int rank = -1;
         String target = null;
         for (ShortcutInfoCompat s : shortcuts) {
@@ -827,7 +825,7 @@
         return sShortcutInfoChangeListeners;
     }
 
-    private static int getIconDimensionInternal(@NonNull final Context context,
+    private static int getIconDimensionInternal(final @NonNull Context context,
             final boolean isHorizontal) {
         final ActivityManager am = (ActivityManager)
                 context.getSystemService(Context.ACTIVITY_SERVICE);
@@ -905,9 +903,8 @@
         return sShortcutInfoChangeListeners;
     }
 
-    @NonNull
-    private static List<ShortcutInfoCompat> removeShortcutsExcludedFromSurface(
-            @NonNull final List<ShortcutInfoCompat> shortcuts, final int surfaces) {
+    private static @NonNull List<ShortcutInfoCompat> removeShortcutsExcludedFromSurface(
+            final @NonNull List<ShortcutInfoCompat> shortcuts, final int surfaces) {
         Objects.requireNonNull(shortcuts);
         if (Build.VERSION.SDK_INT > 32) return shortcuts;
         final List<ShortcutInfoCompat> clone = new ArrayList<>(shortcuts);
@@ -921,7 +918,7 @@
 
     @RequiresApi(25)
     private static class Api25Impl {
-        static String getShortcutInfoWithLowestRank(@NonNull final List<ShortcutInfo> shortcuts) {
+        static String getShortcutInfoWithLowestRank(final @NonNull List<ShortcutInfo> shortcuts) {
             int rank = -1;
             String target = null;
             for (ShortcutInfo s : shortcuts) {
diff --git a/core/core/src/main/java/androidx/core/content/pm/ShortcutXmlParser.java b/core/core/src/main/java/androidx/core/content/pm/ShortcutXmlParser.java
index 82eeb8f..4b3f45f 100644
--- a/core/core/src/main/java/androidx/core/content/pm/ShortcutXmlParser.java
+++ b/core/core/src/main/java/androidx/core/content/pm/ShortcutXmlParser.java
@@ -27,11 +27,11 @@
 import android.os.Bundle;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.annotation.WorkerThread;
 
+import org.jspecify.annotations.NonNull;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -62,8 +62,7 @@
      * Returns a singleton instance of list of ids of static shortcuts parsed from shortcuts.xml
      */
     @WorkerThread
-    @NonNull
-    public static List<String> getShortcutIds(@NonNull final Context context) {
+    public static @NonNull List<String> getShortcutIds(final @NonNull Context context) {
         if (sShortcutIds == null) {
             synchronized (GET_INSTANCE_LOCK) {
                 if (sShortcutIds == null) {
@@ -84,9 +83,8 @@
      * Calling package is determined by {@link Context#getPackageName}
      * Returns a set of string which contains the ids of static shortcuts.
      */
-    @NonNull
     @SuppressWarnings("deprecation")
-    private static Set<String> parseShortcutIds(@NonNull final Context context) {
+    private static @NonNull Set<String> parseShortcutIds(final @NonNull Context context) {
         final Set<String> result = new HashSet<>();
         final Intent mainIntent = new Intent(Intent.ACTION_MAIN);
         mainIntent.addCategory(Intent.CATEGORY_LAUNCHER);
@@ -115,8 +113,8 @@
         return result;
     }
 
-    @NonNull
-    private static XmlResourceParser getXmlResourceParser(Context context, ActivityInfo info) {
+    private static @NonNull XmlResourceParser getXmlResourceParser(Context context,
+            ActivityInfo info) {
         final XmlResourceParser parser = info.loadXmlMetaData(context.getPackageManager(),
                 META_DATA_APP_SHORTCUTS);
         if (parser == null) {
@@ -131,8 +129,7 @@
      * Parses the shortcut ids from given XmlPullParser.
      */
     @VisibleForTesting
-    @NonNull
-    public static List<String> parseShortcutIds(@NonNull final XmlPullParser parser)
+    public static @NonNull List<String> parseShortcutIds(final @NonNull XmlPullParser parser)
             throws IOException, XmlPullParserException {
 
         final List<String> result = new ArrayList<>(1);
diff --git a/core/core/src/main/java/androidx/core/content/res/CamColor.java b/core/core/src/main/java/androidx/core/content/res/CamColor.java
index 9cd05c0..24a52a3 100644
--- a/core/core/src/main/java/androidx/core/content/res/CamColor.java
+++ b/core/core/src/main/java/androidx/core/content/res/CamColor.java
@@ -18,12 +18,13 @@
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.Size;
 import androidx.core.graphics.ColorUtils;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and
  * coupled to a gamut mapping algorithm. Creates a color system, enables a digital design system.
@@ -158,8 +159,7 @@
      *
      * The alpha component is ignored, CamColor only represents opaque colors.
      */
-    @NonNull
-    static CamColor fromColor(@ColorInt int color) {
+    static @NonNull CamColor fromColor(@ColorInt int color) {
         float[] outCamColor = new float[7];
         float[] outM3HCT = new float[3];
         fromColorInViewingConditions(color, ViewingConditions.DEFAULT, outCamColor, outM3HCT);
@@ -187,7 +187,7 @@
      *      Chroma, Tone).
      */
     public static void getM3HCTfromColor(@ColorInt int color,
-            @NonNull @Size(3) float[] outM3HCT) {
+            @Size(3) float @NonNull [] outM3HCT) {
         fromColorInViewingConditions(color, ViewingConditions.DEFAULT, null, outM3HCT);
         outM3HCT[2] = CamUtils.lStarFromInt(color);
     }
@@ -197,8 +197,8 @@
      * ViewingConditions in which the color was viewed. Prefer Cam.fromColor.
      */
     static void fromColorInViewingConditions(@ColorInt int color,
-            @NonNull ViewingConditions viewingConditions, @Nullable @Size(7) float[] outCamColor,
-            @NonNull @Size(3) float[] outM3HCT) {
+            @NonNull ViewingConditions viewingConditions, @Size(7) float @Nullable [] outCamColor,
+            @Size(3) float @NonNull [] outM3HCT) {
         // Transform ARGB int to XYZ, reusing outM3HCT array to avoid a new allocation.
         CamUtils.xyzFromInt(color, outM3HCT);
         float[] xyz = outM3HCT;
@@ -291,8 +291,7 @@
      * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates
      * were measured in the default ViewingConditions.
      */
-    @NonNull
-    private static CamColor fromJch(@FloatRange(from = 0.0, to = 100.0) float j,
+    private static @NonNull CamColor fromJch(@FloatRange(from = 0.0, to = 100.0) float j,
             @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false) float c,
             @FloatRange(from = 0.0, to = 360.0) float h) {
         return fromJchInFrame(j, c, h, ViewingConditions.DEFAULT);
@@ -302,8 +301,7 @@
      * Create a CAM from lightness, chroma, and hue coordinates, and also specify the
      * ViewingConditions where the color was seen.
      */
-    @NonNull
-    private static CamColor fromJchInFrame(@FloatRange(from = 0.0, to = 100.0) float j,
+    private static @NonNull CamColor fromJchInFrame(@FloatRange(from = 0.0, to = 100.0) float j,
             @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false) float c,
             @FloatRange(from = 0.0, to = 360.0) float h, ViewingConditions viewingConditions) {
         float q =
@@ -520,8 +518,7 @@
     // color space.
     //
     // Returns null if no J could be found that generated a color with L* `lstar`.
-    @Nullable
-    private static CamColor findCamByJ(@FloatRange(from = 0.0, to = 360.0) float hue,
+    private static @Nullable CamColor findCamByJ(@FloatRange(from = 0.0, to = 360.0) float hue,
             @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
                     float chroma,
             @FloatRange(from = 0.0, to = 100.0) float lstar) {
diff --git a/core/core/src/main/java/androidx/core/content/res/CamUtils.java b/core/core/src/main/java/androidx/core/content/res/CamUtils.java
index 55435fd..5a0e459 100644
--- a/core/core/src/main/java/androidx/core/content/res/CamUtils.java
+++ b/core/core/src/main/java/androidx/core/content/res/CamUtils.java
@@ -18,9 +18,10 @@
 
 import android.graphics.Color;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.ColorUtils;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Collection of methods for transforming between color spaces.
  *
@@ -132,7 +133,7 @@
         return y;
     }
 
-    static void xyzFromInt(int argb, @NonNull float[] outXYZ) {
+    static void xyzFromInt(int argb, float @NonNull [] outXYZ) {
         final float r = linearized(Color.red(argb));
         final float g = linearized(Color.green(argb));
         final float b = linearized(Color.blue(argb));
diff --git a/core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java b/core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java
index 6ab7be4..c4a87cc 100644
--- a/core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java
+++ b/core/core/src/main/java/androidx/core/content/res/ColorStateListInflaterCompat.java
@@ -32,13 +32,13 @@
 import androidx.annotation.ColorInt;
 import androidx.annotation.ColorRes;
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.XmlRes;
 import androidx.core.R;
 import androidx.core.math.MathUtils;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -63,9 +63,8 @@
      * @param theme     Optional theme to apply to the color, may be {@code null}.
      * @return A new color state list.
      */
-    @Nullable
-    public static ColorStateList inflate(@NonNull Resources resources, @XmlRes int resId,
-            @Nullable Resources.Theme theme) {
+    public static @Nullable ColorStateList inflate(@NonNull Resources resources, @XmlRes int resId,
+            Resources.@Nullable Theme theme) {
         try {
             XmlPullParser parser = resources.getXml(resId);
             return createFromXml(resources, parser, theme);
@@ -85,9 +84,9 @@
      *               {@code null}.
      * @return A new color state list.
      */
-    @NonNull
-    public static ColorStateList createFromXml(@NonNull Resources r, @NonNull XmlPullParser parser,
-            @Nullable Resources.Theme theme) throws XmlPullParserException, IOException {
+    public static @NonNull ColorStateList createFromXml(@NonNull Resources r,
+            @NonNull XmlPullParser parser, Resources.@Nullable Theme theme)
+            throws XmlPullParserException, IOException {
         final AttributeSet attrs = Xml.asAttributeSet(parser);
 
         int type;
@@ -110,10 +109,9 @@
      * @return A new color state list for the current tag.
      * @throws XmlPullParserException if the current tag is not &lt;selector>
      */
-    @NonNull
-    public static ColorStateList createFromXmlInner(@NonNull Resources r,
+    public static @NonNull ColorStateList createFromXmlInner(@NonNull Resources r,
             @NonNull XmlPullParser parser, @NonNull AttributeSet attrs,
-            @Nullable Resources.Theme theme)
+            Resources.@Nullable Theme theme)
             throws XmlPullParserException, IOException {
         final String name = parser.getName();
         if (!name.equals("selector")) {
@@ -128,7 +126,7 @@
      * Fill in this object based on the contents of an XML "selector" element.
      */
     private static ColorStateList inflate(@NonNull Resources r, @NonNull XmlPullParser parser,
-            @NonNull AttributeSet attrs, @Nullable Resources.Theme theme)
+            @NonNull AttributeSet attrs, Resources.@Nullable Theme theme)
             throws XmlPullParserException, IOException {
         final int innerDepth = parser.getDepth() + 1;
         int depth;
@@ -218,8 +216,7 @@
                 && value.type <= TypedValue.TYPE_LAST_COLOR_INT;
     }
 
-    @NonNull
-    private static TypedValue getTypedValue() {
+    private static @NonNull TypedValue getTypedValue() {
         TypedValue tv = sTempTypedValue.get();
         if (tv == null) {
             tv = new TypedValue();
diff --git a/core/core/src/main/java/androidx/core/content/res/ComplexColorCompat.java b/core/core/src/main/java/androidx/core/content/res/ComplexColorCompat.java
index ed22996..2e480fb 100644
--- a/core/core/src/main/java/androidx/core/content/res/ComplexColorCompat.java
+++ b/core/core/src/main/java/androidx/core/content/res/ComplexColorCompat.java
@@ -30,10 +30,10 @@
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.ColorRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -75,8 +75,7 @@
         return new ComplexColorCompat(null, null, color);
     }
 
-    @Nullable
-    public Shader getShader() {
+    public @Nullable Shader getShader() {
         return mShader;
     }
 
@@ -132,9 +131,8 @@
      * @param theme     Optional theme to apply to the color, may be {@code null}.
      * @return A new color.
      */
-    @Nullable
-    public static ComplexColorCompat inflate(@NonNull Resources resources, @ColorRes int resId,
-            @Nullable Resources.Theme theme) {
+    public static @Nullable ComplexColorCompat inflate(@NonNull Resources resources,
+            @ColorRes int resId, Resources.@Nullable Theme theme) {
         try {
             return createFromXml(resources, resId, theme);
         } catch (Exception e) {
@@ -143,9 +141,8 @@
         return null;
     }
 
-    @NonNull
-    private static ComplexColorCompat createFromXml(@NonNull Resources resources,
-            @ColorRes int resId, @Nullable Resources.Theme theme)
+    private static @NonNull ComplexColorCompat createFromXml(@NonNull Resources resources,
+            @ColorRes int resId, Resources.@Nullable Theme theme)
             throws IOException, XmlPullParserException {
         @SuppressLint("ResourceType")
         XmlPullParser parser = resources.getXml(resId);
diff --git a/core/core/src/main/java/androidx/core/content/res/ConfigurationHelper.java b/core/core/src/main/java/androidx/core/content/res/ConfigurationHelper.java
index dcfb64c..becebe4 100644
--- a/core/core/src/main/java/androidx/core/content/res/ConfigurationHelper.java
+++ b/core/core/src/main/java/androidx/core/content/res/ConfigurationHelper.java
@@ -19,7 +19,7 @@
 import android.content.res.Configuration;
 import android.content.res.Resources;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper class which allows access to properties of {@link Configuration} in
diff --git a/core/core/src/main/java/androidx/core/content/res/FontResourcesParserCompat.java b/core/core/src/main/java/androidx/core/content/res/FontResourcesParserCompat.java
index 55b813c..9582516 100644
--- a/core/core/src/main/java/androidx/core/content/res/FontResourcesParserCompat.java
+++ b/core/core/src/main/java/androidx/core/content/res/FontResourcesParserCompat.java
@@ -29,13 +29,13 @@
 
 import androidx.annotation.ArrayRes;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.R;
 import androidx.core.provider.FontRequest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -101,8 +101,7 @@
             return mRequest;
         }
 
-        @Nullable
-        public FontRequest getFallbackRequest() {
+        public @Nullable FontRequest getFallbackRequest() {
             return mFallbackRequest;
         }
 
@@ -170,13 +169,13 @@
      * A class that represents a file based font-family element in an xml font file.
      */
     public static final class FontFamilyFilesResourceEntry implements FamilyResourceEntry {
-        private final @NonNull FontFileResourceEntry[] mEntries;
+        private final FontFileResourceEntry @NonNull [] mEntries;
 
-        public FontFamilyFilesResourceEntry(@NonNull FontFileResourceEntry[] entries) {
+        public FontFamilyFilesResourceEntry(FontFileResourceEntry @NonNull [] entries) {
             mEntries = entries;
         }
 
-        public @NonNull FontFileResourceEntry[] getEntries() {
+        public FontFileResourceEntry @NonNull [] getEntries() {
             return mEntries;
         }
     }
@@ -281,9 +280,8 @@
      *
      * Provider cert entry must be cert string array or array of cert string array.
      */
-    @NonNull
     @SuppressWarnings("MixedMutabilityReturnType")
-    public static List<List<byte[]>> readCerts(@NonNull Resources resources,
+    public static @NonNull List<List<byte[]>> readCerts(@NonNull Resources resources,
             @ArrayRes int certsId) {
         if (certsId == 0) {
             return Collections.emptyList();
diff --git a/core/core/src/main/java/androidx/core/content/res/GradientColorInflaterCompat.java b/core/core/src/main/java/androidx/core/content/res/GradientColorInflaterCompat.java
index 37fcafd..773cf40 100644
--- a/core/core/src/main/java/androidx/core/content/res/GradientColorInflaterCompat.java
+++ b/core/core/src/main/java/androidx/core/content/res/GradientColorInflaterCompat.java
@@ -34,11 +34,11 @@
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -66,7 +66,7 @@
     }
 
     static Shader createFromXml(@NonNull Resources resources, @NonNull XmlPullParser parser,
-            @Nullable Resources.Theme theme) throws XmlPullParserException, IOException {
+            Resources.@Nullable Theme theme) throws XmlPullParserException, IOException {
         final AttributeSet attrs = Xml.asAttributeSet(parser);
 
         int type;
@@ -84,7 +84,7 @@
 
     static Shader createFromXmlInner(@NonNull Resources resources,
             @NonNull XmlPullParser parser, @NonNull AttributeSet attrs,
-            @Nullable Resources.Theme theme)
+            Resources.@Nullable Theme theme)
             throws IOException, XmlPullParserException {
         final String name = parser.getName();
         if (!name.equals("gradient")) {
@@ -144,7 +144,7 @@
 
     private static ColorStops inflateChildElements(@NonNull Resources resources,
             @NonNull XmlPullParser parser, @NonNull AttributeSet attrs,
-            @Nullable Resources.Theme theme)
+            Resources.@Nullable Theme theme)
             throws XmlPullParserException, IOException {
         final int innerDepth = parser.getDepth() + 1;
         int type;
diff --git a/core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java b/core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java
index 9ba0cc71..29f37eb 100644
--- a/core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java
+++ b/core/core/src/main/java/androidx/core/content/res/ResourcesCompat.java
@@ -45,8 +45,6 @@
 import androidx.annotation.DrawableRes;
 import androidx.annotation.FontRes;
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.FontResourcesParserCompat.FamilyResourceEntry;
@@ -56,6 +54,8 @@
 import androidx.core.util.ObjectsCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -129,9 +129,8 @@
      * @throws NotFoundException Throws NotFoundException if the given ID does
      *                           not exist.
      */
-    @Nullable
     @SuppressWarnings("deprecation")
-    public static Drawable getDrawable(@NonNull Resources res, @DrawableRes int id,
+    public static @Nullable Drawable getDrawable(@NonNull Resources res, @DrawableRes int id,
             @Nullable Theme theme) throws NotFoundException {
         if (SDK_INT >= 21) {
             return Api21Impl.getDrawable(res, id, theme);
@@ -163,10 +162,9 @@
      * @throws NotFoundException Throws NotFoundException if the given ID does
      *                           not exist.
      */
-    @Nullable
     @SuppressWarnings("deprecation")
-    public static Drawable getDrawableForDensity(@NonNull Resources res, @DrawableRes int id,
-            int density, @Nullable Theme theme) throws NotFoundException {
+    public static @Nullable Drawable getDrawableForDensity(@NonNull Resources res,
+            @DrawableRes int id, int density, @Nullable Theme theme) throws NotFoundException {
         if (SDK_INT >= 21) {
             return Api21Impl.getDrawableForDensity(res, id, density, theme);
         } else {
@@ -220,10 +218,9 @@
      * @throws NotFoundException Throws NotFoundException if the given ID does
      *                           not exist.
      */
-    @Nullable
     @SuppressWarnings("deprecation")
-    public static ColorStateList getColorStateList(@NonNull Resources res, @ColorRes int id,
-            @Nullable Theme theme) throws NotFoundException {
+    public static @Nullable ColorStateList getColorStateList(@NonNull Resources res,
+            @ColorRes int id, @Nullable Theme theme) throws NotFoundException {
         // We explicitly do not attempt to use the platform Resources impl on S+
         // in case the CSL is using only app:lStar
 
@@ -251,8 +248,7 @@
     /**
      * Inflates a {@link ColorStateList} from resources, honouring theme attributes.
      */
-    @Nullable
-    private static ColorStateList inflateColorStateList(Resources resources, int resId,
+    private static @Nullable ColorStateList inflateColorStateList(Resources resources, int resId,
             @Nullable Theme theme) {
         if (isColorInt(resources, resId)) {
             // The resource is a color int, we can't handle it so return null
@@ -267,9 +263,8 @@
         return null;
     }
 
-    @Nullable
-    private static ColorStateList getCachedColorStateList(@NonNull ColorStateListCacheKey key,
-            @ColorRes int resId) {
+    private static @Nullable ColorStateList getCachedColorStateList(
+            @NonNull ColorStateListCacheKey key, @ColorRes int resId) {
         synchronized (sColorStateCacheLock) {
             final SparseArray<ColorStateListCacheEntry> entries = sColorStateCaches.get(key);
             if (entries != null && entries.size() > 0) {
@@ -312,8 +307,7 @@
                 && value.type <= TypedValue.TYPE_LAST_COLOR_INT;
     }
 
-    @NonNull
-    private static TypedValue getTypedValue() {
+    private static @NonNull TypedValue getTypedValue() {
         TypedValue tv = sTempTypedValue.get();
         if (tv == null) {
             tv = new TypedValue();
@@ -404,8 +398,7 @@
      * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
      * @see #getFont(Context, int, FontCallback, Handler)
      */
-    @Nullable
-    public static Typeface getFont(@NonNull Context context, @FontRes int id)
+    public static @Nullable Typeface getFont(@NonNull Context context, @FontRes int id)
             throws NotFoundException {
         if (context.isRestricted()) {
             return null;
@@ -432,8 +425,7 @@
      * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
      * @see #getFont(Context, int, FontCallback, Handler)
      */
-    @Nullable
-    public static Typeface getCachedFont(@NonNull Context context, @FontRes int id)
+    public static @Nullable Typeface getCachedFont(@NonNull Context context, @FontRes int id)
             throws NotFoundException {
         if (context.isRestricted()) {
             return null;
@@ -491,8 +483,7 @@
         }
 
         @RestrictTo(LIBRARY)
-        @NonNull
-        public static Handler getHandler(@Nullable Handler handler) {
+        public static @NonNull Handler getHandler(@Nullable Handler handler) {
             return handler == null ? new Handler(Looper.getMainLooper()) : handler;
         }
     }
@@ -533,9 +524,8 @@
      * Used by TintTypedArray.
      *
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public static Typeface getFont(@NonNull Context context, @FontRes int id,
+    public static @Nullable Typeface getFont(@NonNull Context context, @FontRes int id,
             @NonNull TypedValue value, int style, @Nullable FontCallback fontCallback)
             throws NotFoundException {
         if (context.isRestricted()) {
@@ -678,8 +668,7 @@
             // This class is not instantiable.
         }
 
-        @NonNull
-        static ColorStateList getColorStateList(@NonNull Resources res, @ColorRes int id,
+        static @NonNull ColorStateList getColorStateList(@NonNull Resources res, @ColorRes int id,
                 @Nullable Theme theme) {
             return res.getColorStateList(id, theme);
         }
diff --git a/core/core/src/main/java/androidx/core/content/res/TypedArrayUtils.java b/core/core/src/main/java/androidx/core/content/res/TypedArrayUtils.java
index 4ce9005..aed89e1 100644
--- a/core/core/src/main/java/androidx/core/content/res/TypedArrayUtils.java
+++ b/core/core/src/main/java/androidx/core/content/res/TypedArrayUtils.java
@@ -27,11 +27,11 @@
 
 import androidx.annotation.AnyRes;
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.StyleableRes;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 
 /**
@@ -134,7 +134,7 @@
      * or containing {@code defaultValue} if it does not exist.
      */
     public static ComplexColorCompat getNamedComplexColor(@NonNull TypedArray a,
-            @NonNull XmlPullParser parser, @Nullable Resources.Theme theme,
+            @NonNull XmlPullParser parser, Resources.@Nullable Theme theme,
             @NonNull String attrName, @StyleableRes int resId, @ColorInt int defaultValue) {
         if (hasAttribute(parser, attrName)) {
             // first check if is a simple color
@@ -160,9 +160,8 @@
      * @return a color state list object form the {@link TypedArray} with the specified
      * {@code resId}, or null if it does not exist.
      */
-    @Nullable
-    public static ColorStateList getNamedColorStateList(@NonNull TypedArray a,
-            @NonNull XmlPullParser parser, @Nullable Resources.Theme theme,
+    public static @Nullable ColorStateList getNamedColorStateList(@NonNull TypedArray a,
+            @NonNull XmlPullParser parser, Resources.@Nullable Theme theme,
             @NonNull String attrName, @StyleableRes int resId) {
         if (hasAttribute(parser, attrName)) {
             final TypedValue value = new TypedValue();
@@ -181,8 +180,8 @@
         return null;
     }
 
-    @NonNull
-    private static ColorStateList getNamedColorStateListFromInt(@NonNull TypedValue value) {
+    private static @NonNull ColorStateList getNamedColorStateListFromInt(
+            @NonNull TypedValue value) {
         // This is copied from ResourcesImpl#getNamedColorStateListFromInt in the platform, but the
         // ComplexColor caching mechanism has been removed. The practical implication of this is
         // minimal, since platform caching is only used by Zygote-preloaded resources.
@@ -214,9 +213,8 @@
      * @return a string value in the {@link TypedArray} with the specified {@code resId}, or
      * null if it does not exist.
      */
-    @Nullable
-    public static String getNamedString(@NonNull TypedArray a, @NonNull XmlPullParser parser,
-            @NonNull String attrName, @StyleableRes int resId) {
+    public static @Nullable String getNamedString(@NonNull TypedArray a,
+            @NonNull XmlPullParser parser, @NonNull String attrName, @StyleableRes int resId) {
         final boolean hasAttr = hasAttribute(parser, attrName);
         if (!hasAttr) {
             return null;
@@ -230,9 +228,8 @@
      * and return a temporary object holding its data.  This object is only
      * valid until the next call on to {@link TypedArray}.
      */
-    @Nullable
-    public static TypedValue peekNamedValue(@NonNull TypedArray a, @NonNull XmlPullParser parser,
-            @NonNull String attrName, int resId) {
+    public static @Nullable TypedValue peekNamedValue(@NonNull TypedArray a,
+            @NonNull XmlPullParser parser, @NonNull String attrName, int resId) {
         final boolean hasAttr = hasAttribute(parser, attrName);
         if (!hasAttr) {
             return null;
@@ -245,9 +242,8 @@
      * Obtains styled attributes from the theme, if available, or unstyled
      * resources if the theme is null.
      */
-    @NonNull
-    public static TypedArray obtainAttributes(@NonNull Resources res,
-            @Nullable Resources.Theme theme, @NonNull AttributeSet set, @NonNull int[] attrs) {
+    public static @NonNull TypedArray obtainAttributes(@NonNull Resources res,
+            Resources.@Nullable Theme theme, @NonNull AttributeSet set, int @NonNull [] attrs) {
         if (theme == null) {
             return res.obtainAttributes(set, attrs);
         }
@@ -268,8 +264,7 @@
      * @return a drawable value of {@code index}. If it does not exist, a drawable value of
      * {@code fallbackIndex}. If it still does not exist, {@code null}.
      */
-    @Nullable
-    public static Drawable getDrawable(@NonNull TypedArray a, @StyleableRes int index,
+    public static @Nullable Drawable getDrawable(@NonNull TypedArray a, @StyleableRes int index,
             @StyleableRes int fallbackIndex) {
         Drawable val = a.getDrawable(index);
         if (val == null) {
@@ -303,8 +298,7 @@
      * @return a string value of {@code index}. If it does not exist, a string value of
      * {@code fallbackIndex}. If it still does not exist, {@code null}.
      */
-    @Nullable
-    public static String getString(@NonNull TypedArray a, @StyleableRes int index,
+    public static @Nullable String getString(@NonNull TypedArray a, @StyleableRes int index,
             @StyleableRes int fallbackIndex) {
         String val = a.getString(index);
         if (val == null) {
@@ -319,8 +313,7 @@
      * @return a text value of {@code index}. If it does not exist, a text value of
      * {@code fallbackIndex}. If it still does not exist, {@code null}.
      */
-    @Nullable
-    public static CharSequence getText(@NonNull TypedArray a, @StyleableRes int index,
+    public static @Nullable CharSequence getText(@NonNull TypedArray a, @StyleableRes int index,
             @StyleableRes int fallbackIndex) {
         CharSequence val = a.getText(index);
         if (val == null) {
@@ -335,9 +328,8 @@
      * @return a string array value of {@code index}. If it does not exist, a string array value
      * of {@code fallbackIndex}. If it still does not exist, {@code null}.
      */
-    @Nullable
-    public static CharSequence[] getTextArray(@NonNull TypedArray a, @StyleableRes int index,
-            @StyleableRes int fallbackIndex) {
+    public static CharSequence @Nullable [] getTextArray(@NonNull TypedArray a,
+            @StyleableRes int index, @StyleableRes int fallbackIndex) {
         CharSequence[] val = a.getTextArray(index);
         if (val == null) {
             val = a.getTextArray(fallbackIndex);
diff --git a/core/core/src/main/java/androidx/core/content/res/ViewingConditions.java b/core/core/src/main/java/androidx/core/content/res/ViewingConditions.java
index 1768c07..7b2c4ae 100644
--- a/core/core/src/main/java/androidx/core/content/res/ViewingConditions.java
+++ b/core/core/src/main/java/androidx/core/content/res/ViewingConditions.java
@@ -16,7 +16,7 @@
 
 package androidx.core.content.res;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Viewing conditions define parameters of where a color was seen. Used, along with a color, to
@@ -91,8 +91,7 @@
         return mNc;
     }
 
-    @NonNull
-    float[] getRgbD() {
+    float @NonNull [] getRgbD() {
         return mRgbD;
     }
 
@@ -123,8 +122,7 @@
     }
 
     /** Create a custom camFrame. */
-    @NonNull
-    static ViewingConditions make(@NonNull float[] whitepoint, float adaptingLuminance,
+    static @NonNull ViewingConditions make(float @NonNull [] whitepoint, float adaptingLuminance,
             float backgroundLstar, float surround, boolean discountingIlluminant) {
         // Transform white point XYZ to 'cone'/'rgb' responses
         float[][] matrix = CamUtils.XYZ_TO_CAM16RGB;
diff --git a/core/core/src/main/java/androidx/core/database/CursorWindowCompat.java b/core/core/src/main/java/androidx/core/database/CursorWindowCompat.java
index 04202eb..433aca7 100644
--- a/core/core/src/main/java/androidx/core/database/CursorWindowCompat.java
+++ b/core/core/src/main/java/androidx/core/database/CursorWindowCompat.java
@@ -19,10 +19,11 @@
 import android.database.CursorWindow;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link CursorWindow}
  */
@@ -38,8 +39,7 @@
      * Prior to Android P, this method will return a CursorWindow of size defined by the platform.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static CursorWindow create(@Nullable String name, long windowSizeBytes) {
+    public static @NonNull CursorWindow create(@Nullable String name, long windowSizeBytes) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.createCursorWindow(name, windowSizeBytes);
         } else {
diff --git a/core/core/src/main/java/androidx/core/database/sqlite/SQLiteCursorCompat.java b/core/core/src/main/java/androidx/core/database/sqlite/SQLiteCursorCompat.java
index 00746f4..95466bb 100644
--- a/core/core/src/main/java/androidx/core/database/sqlite/SQLiteCursorCompat.java
+++ b/core/core/src/main/java/androidx/core/database/sqlite/SQLiteCursorCompat.java
@@ -20,9 +20,10 @@
 import android.database.sqlite.SQLiteCursor;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link AbstractWindowedCursor}
  */
diff --git a/core/core/src/main/java/androidx/core/graphics/BitmapCompat.java b/core/core/src/main/java/androidx/core/graphics/BitmapCompat.java
index ea4f04a..432e006 100644
--- a/core/core/src/main/java/androidx/core/graphics/BitmapCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/BitmapCompat.java
@@ -26,11 +26,12 @@
 import android.hardware.HardwareBuffer;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link Bitmap}.
  */
@@ -128,8 +129,7 @@
      *                           level 26 and earlier, this parameter has no effect).
      * @return A new bitmap in the requested size.
      */
-    public static @NonNull
-    Bitmap createScaledBitmap(@NonNull Bitmap srcBm, int dstW,
+    public static     @NonNull Bitmap createScaledBitmap(@NonNull Bitmap srcBm, int dstW,
             int dstH, @Nullable Rect srcRect, boolean scaleInLinearSpace) {
         if (dstW <= 0 || dstH <= 0) {
             throw new IllegalArgumentException("dstW and dstH must be > 0!");
diff --git a/core/core/src/main/java/androidx/core/graphics/BlendModeColorFilterCompat.java b/core/core/src/main/java/androidx/core/graphics/BlendModeColorFilterCompat.java
index f12da61..0eaf8e9 100644
--- a/core/core/src/main/java/androidx/core/graphics/BlendModeColorFilterCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/BlendModeColorFilterCompat.java
@@ -23,10 +23,11 @@
 import android.graphics.PorterDuffColorFilter;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing ColorFilter APIs on various API levels of the platform
  */
diff --git a/core/core/src/main/java/androidx/core/graphics/BlendModeUtils.java b/core/core/src/main/java/androidx/core/graphics/BlendModeUtils.java
index f610f9a..5d54947 100644
--- a/core/core/src/main/java/androidx/core/graphics/BlendModeUtils.java
+++ b/core/core/src/main/java/androidx/core/graphics/BlendModeUtils.java
@@ -19,10 +19,11 @@
 import android.graphics.BlendMode;
 import android.graphics.PorterDuff;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Utility class used to map BlendModeCompat parameters to the corresponding
  * PorterDuff mode or BlendMode depending on the API level of the platform
@@ -38,8 +39,8 @@
             // This class is not instantiable.
         }
 
-        @Nullable
-        static Object obtainBlendModeFromCompat(@NonNull BlendModeCompat blendModeCompat) {
+        static @Nullable Object obtainBlendModeFromCompat(
+                @NonNull BlendModeCompat blendModeCompat) {
             switch (blendModeCompat) {
                 case CLEAR:
                     return BlendMode.CLEAR;
@@ -105,7 +106,7 @@
         }
     }
 
-    static @Nullable PorterDuff.Mode obtainPorterDuffFromCompat(
+    static PorterDuff.@Nullable Mode obtainPorterDuffFromCompat(
             @Nullable BlendModeCompat blendModeCompat) {
         if (blendModeCompat == null) {
             return null;
diff --git a/core/core/src/main/java/androidx/core/graphics/ColorUtils.java b/core/core/src/main/java/androidx/core/graphics/ColorUtils.java
index a7b93c7..a04bdef 100644
--- a/core/core/src/main/java/androidx/core/graphics/ColorUtils.java
+++ b/core/core/src/main/java/androidx/core/graphics/ColorUtils.java
@@ -22,12 +22,13 @@
 import androidx.annotation.ColorInt;
 import androidx.annotation.FloatRange;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.Size;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.content.res.CamColor;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Objects;
 
 /**
@@ -89,8 +90,8 @@
      * {@linkplain android.graphics.Color#getModel models} of the colors do not match
      */
     @RequiresApi(26)
-    @NonNull
-    public static Color compositeColors(@NonNull Color foreground, @NonNull Color background) {
+    public static @NonNull Color compositeColors(@NonNull Color foreground,
+            @NonNull Color background) {
         return Api26Impl.compositeColors(foreground, background);
     }
 
@@ -250,7 +251,7 @@
      */
     public static void RGBToHSL(@IntRange(from = 0x0, to = 0xFF) int r,
             @IntRange(from = 0x0, to = 0xFF) int g, @IntRange(from = 0x0, to = 0xFF) int b,
-            @NonNull float[] outHsl) {
+            float @NonNull [] outHsl) {
         final float rf = r / 255f;
         final float gf = g / 255f;
         final float bf = b / 255f;
@@ -298,7 +299,7 @@
      * @param color the ARGB color to convert. The alpha component is ignored
      * @param outHsl 3-element array which holds the resulting HSL components
      */
-    public static void colorToHSL(@ColorInt int color, @NonNull float[] outHsl) {
+    public static void colorToHSL(@ColorInt int color, float @NonNull [] outHsl) {
         RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), outHsl);
     }
 
@@ -315,7 +316,7 @@
      * @return the resulting RGB color
      */
     @ColorInt
-    public static int HSLToColor(@NonNull float[] hsl) {
+    public static int HSLToColor(float @NonNull [] hsl) {
         final float h = hsl[0];
         final float s = hsl[1];
         final float l = hsl[2];
@@ -387,7 +388,7 @@
      * @param color the ARGB color to convert. The alpha component is ignored
      * @param outLab 3-element array which holds the resulting LAB components
      */
-    public static void colorToLAB(@ColorInt int color, @NonNull double[] outLab) {
+    public static void colorToLAB(@ColorInt int color, double @NonNull [] outLab) {
         RGBToLAB(Color.red(color), Color.green(color), Color.blue(color), outLab);
     }
 
@@ -407,7 +408,7 @@
      */
     public static void RGBToLAB(@IntRange(from = 0x0, to = 0xFF) int r,
             @IntRange(from = 0x0, to = 0xFF) int g, @IntRange(from = 0x0, to = 0xFF) int b,
-            @NonNull double[] outLab) {
+            double @NonNull [] outLab) {
         // First we convert RGB to XYZ
         RGBToXYZ(r, g, b, outLab);
         // outLab now contains XYZ
@@ -430,7 +431,7 @@
      * @param color the ARGB color to convert. The alpha component is ignored
      * @param outXyz 3-element array which holds the resulting LAB components
      */
-    public static void colorToXYZ(@ColorInt int color, @NonNull double[] outXyz) {
+    public static void colorToXYZ(@ColorInt int color, double @NonNull [] outXyz) {
         RGBToXYZ(Color.red(color), Color.green(color), Color.blue(color), outXyz);
     }
 
@@ -453,7 +454,7 @@
      */
     public static void RGBToXYZ(@IntRange(from = 0x0, to = 0xFF) int r,
             @IntRange(from = 0x0, to = 0xFF) int g, @IntRange(from = 0x0, to = 0xFF) int b,
-            @NonNull double[] outXyz) {
+            double @NonNull [] outXyz) {
         if (outXyz.length != 3) {
             throw new IllegalArgumentException("outXyz must have a length of 3.");
         }
@@ -490,7 +491,7 @@
     public static void XYZToLAB(@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_X) double x,
             @FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Y) double y,
             @FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Z) double z,
-            @NonNull double[] outLab) {
+            double @NonNull [] outLab) {
         if (outLab.length != 3) {
             throw new IllegalArgumentException("outLab must have a length of 3.");
         }
@@ -522,7 +523,7 @@
     public static void LABToXYZ(@FloatRange(from = 0f, to = 100) final double l,
             @FloatRange(from = -128, to = 127) final double a,
             @FloatRange(from = -128, to = 127) final double b,
-            @NonNull double[] outXyz) {
+            double @NonNull [] outXyz) {
         final double fy = (l + 16) / 116;
         final double fx = a / 500 + fy;
         final double fz = fy - b / 200;
@@ -589,7 +590,7 @@
      * Returns the euclidean distance between two LAB colors.
      */
     @SuppressWarnings("unused")
-    public static double distanceEuclidean(@NonNull double[] labX, @NonNull double[] labY) {
+    public static double distanceEuclidean(double @NonNull [] labX, double @NonNull [] labY) {
         return Math.sqrt(Math.pow(labX[0] - labY[0], 2)
                 + Math.pow(labX[1] - labY[1], 2)
                 + Math.pow(labX[2] - labY[2], 2));
@@ -646,8 +647,8 @@
      * @param outResult 3-element array which holds the resulting HSL components
      */
     @SuppressWarnings("unused")
-    public static void blendHSL(@NonNull float[] hsl1, @NonNull float[] hsl2,
-            @FloatRange(from = 0.0, to = 1.0) float ratio, @NonNull float[] outResult) {
+    public static void blendHSL(float @NonNull [] hsl1, float @NonNull [] hsl2,
+            @FloatRange(from = 0.0, to = 1.0) float ratio, float @NonNull [] outResult) {
         if (outResult.length != 3) {
             throw new IllegalArgumentException("result must have a length of 3.");
         }
@@ -670,8 +671,8 @@
      * @param outResult 3-element array which holds the resulting LAB components
      */
     @SuppressWarnings("unused")
-    public static void blendLAB(@NonNull double[] lab1, @NonNull double[] lab2,
-            @FloatRange(from = 0.0, to = 1.0) double ratio, @NonNull double[] outResult) {
+    public static void blendLAB(double @NonNull [] lab1, double @NonNull [] lab2,
+            @FloatRange(from = 0.0, to = 1.0) double ratio, double @NonNull [] outResult) {
         if (outResult.length != 3) {
             throw new IllegalArgumentException("outResult must have a length of 3.");
         }
@@ -720,7 +721,7 @@
      *                 Tone).
      */
     @SuppressWarnings("AcronymName")
-    public static void colorToM3HCT(@ColorInt int color, @NonNull @Size(3) float[] outM3HCT) {
+    public static void colorToM3HCT(@ColorInt int color, @Size(3) float @NonNull [] outM3HCT) {
         CamColor.getM3HCTfromColor(color, outM3HCT);
     }
 
diff --git a/core/core/src/main/java/androidx/core/graphics/Insets.java b/core/core/src/main/java/androidx/core/graphics/Insets.java
index f88bff3..7642e34 100644
--- a/core/core/src/main/java/androidx/core/graphics/Insets.java
+++ b/core/core/src/main/java/androidx/core/graphics/Insets.java
@@ -20,10 +20,11 @@
 
 import android.graphics.Rect;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * An Insets instance holds four integer offsets which describe changes to the four
  * edges of a Rectangle. By convention, positive values move edges towards the
@@ -32,8 +33,7 @@
  * Insets are immutable so may be treated as values.
  */
 public final class Insets {
-    @NonNull
-    public static final Insets NONE = new Insets(0, 0, 0, 0);
+    public static final @NonNull Insets NONE = new Insets(0, 0, 0, 0);
 
     public final int left;
     public final int top;
@@ -58,8 +58,7 @@
      * @param bottom the bottom inset
      * @return Insets instance with the appropriate values
      */
-    @NonNull
-    public static Insets of(int left, int top, int right, int bottom) {
+    public static @NonNull Insets of(int left, int top, int right, int bottom) {
         if (left == 0 && top == 0 && right == 0 && bottom == 0) {
             return NONE;
         }
@@ -72,8 +71,7 @@
      * @param r the rectangle from which to take the values
      * @return an Insets instance with the appropriate values
      */
-    @NonNull
-    public static Insets of(@NonNull Rect r) {
+    public static @NonNull Insets of(@NonNull Rect r) {
         return of(r.left, r.top, r.right, r.bottom);
     }
 
@@ -84,8 +82,7 @@
      * @param b The second Insets to add.
      * @return a + b, i. e. all insets on every side are added together.
      */
-    @NonNull
-    public static Insets add(@NonNull Insets a, @NonNull Insets b) {
+    public static @NonNull Insets add(@NonNull Insets a, @NonNull Insets b) {
         return Insets.of(a.left + b.left, a.top + b.top, a.right + b.right, a.bottom + b.bottom);
     }
 
@@ -97,8 +94,7 @@
      * @return a - b, i. e. all insets on every side are subtracted from each other.
      */
     @SuppressWarnings("unused")
-    @NonNull
-    public static Insets subtract(@NonNull Insets a, @NonNull Insets b) {
+    public static @NonNull Insets subtract(@NonNull Insets a, @NonNull Insets b) {
         return Insets.of(a.left - b.left, a.top - b.top, a.right - b.right, a.bottom - b.bottom);
     }
 
@@ -110,8 +106,7 @@
      * @return an {@code Insets} instance where the inset on each side is the larger of
      *         the insets on that side from {@code a} and {@code b}.
      */
-    @NonNull
-    public static Insets max(@NonNull Insets a, @NonNull Insets b) {
+    public static @NonNull Insets max(@NonNull Insets a, @NonNull Insets b) {
         return Insets.of(Math.max(a.left, b.left), Math.max(a.top, b.top),
                 Math.max(a.right, b.right), Math.max(a.bottom, b.bottom));
     }
@@ -124,8 +119,7 @@
      * @return an {@code Insets} instance where the inset on each side is the smaller of
      *         the insets on that side from {@code a} and {@code b}.
      */
-    @NonNull
-    public static Insets min(@NonNull Insets a, @NonNull Insets b) {
+    public static @NonNull Insets min(@NonNull Insets a, @NonNull Insets b) {
         return Insets.of(Math.min(a.left, b.left), Math.min(a.top, b.top),
                 Math.min(a.right, b.right), Math.min(a.bottom, b.bottom));
     }
@@ -162,9 +156,8 @@
         return result;
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return "Insets{left=" + left + ", top=" + top
                 + ", right=" + right + ", bottom=" + bottom + '}';
     }
@@ -173,10 +166,9 @@
      * @deprecated Use {@link #toCompatInsets(android.graphics.Insets)} instead.
      */
     @RequiresApi(api = 29)
-    @NonNull
     @Deprecated
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public static Insets wrap(@NonNull android.graphics.Insets insets) {
+    public static @NonNull Insets wrap(android.graphics.@NonNull Insets insets) {
         return toCompatInsets(insets);
     }
 
@@ -185,8 +177,7 @@
      * {@link Insets} instance from AndroidX.
      */
     @RequiresApi(api = 29)
-    @NonNull
-    public static Insets toCompatInsets(@NonNull android.graphics.Insets insets) {
+    public static @NonNull Insets toCompatInsets(android.graphics.@NonNull Insets insets) {
         return Insets.of(insets.left, insets.top, insets.right, insets.bottom);
     }
 
@@ -195,8 +186,7 @@
      * from the platform.
      */
     @RequiresApi(29)
-    @NonNull
-    public android.graphics.Insets toPlatformInsets() {
+    public android.graphics.@NonNull Insets toPlatformInsets() {
         return Api29Impl.of(left, top, right, bottom);
     }
 
diff --git a/core/core/src/main/java/androidx/core/graphics/PaintCompat.java b/core/core/src/main/java/androidx/core/graphics/PaintCompat.java
index e2e51b4..cc7ae63 100644
--- a/core/core/src/main/java/androidx/core/graphics/PaintCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/PaintCompat.java
@@ -25,11 +25,12 @@
 import android.graphics.Rect;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Pair;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link Paint}.
  */
diff --git a/core/core/src/main/java/androidx/core/graphics/PathParser.java b/core/core/src/main/java/androidx/core/graphics/PathParser.java
index 9f77d78..7de28da 100644
--- a/core/core/src/main/java/androidx/core/graphics/PathParser.java
+++ b/core/core/src/main/java/androidx/core/graphics/PathParser.java
@@ -22,10 +22,11 @@
 import android.graphics.Path;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 // This class is a duplicate from the PathParser.java of frameworks/base, with slight
@@ -73,8 +74,7 @@
      * @param pathData The string representing a path, the same as "d" string in svg file.
      * @return the generated Path object.
      */
-    @NonNull
-    public static Path createPathFromPathData(@NonNull String pathData) {
+    public static @NonNull Path createPathFromPathData(@NonNull String pathData) {
         Path path = new Path();
         PathDataNode[] nodes = createNodesFromPathData(pathData);
         try {
@@ -90,8 +90,7 @@
      * @return an array of the PathDataNode.
      */
     @SuppressWarnings("ArrayReturn")
-    @NonNull
-    public static PathDataNode[] createNodesFromPathData(@NonNull String pathData) {
+    public static PathDataNode @NonNull [] createNodesFromPathData(@NonNull String pathData) {
         int start = 0;
         int end = 1;
 
@@ -118,9 +117,8 @@
      * @return a deep copy of the <code>source</code>.
      */
     @SuppressWarnings("ArrayReturn")
-    @NonNull
-    public static PathDataNode[] deepCopyNodes(
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] source
+    public static PathDataNode @NonNull [] deepCopyNodes(
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] source
     ) {
         PathDataNode[] copy = new PathParser.PathDataNode[source.length];
         for (int i = 0; i < source.length; i++) {
@@ -136,8 +134,8 @@
      */
     @SuppressWarnings("ArrayReturn")
     public static boolean canMorph(
-            @SuppressWarnings("ArrayReturn") @Nullable PathDataNode[] nodesFrom,
-            @SuppressWarnings("ArrayReturn") @Nullable PathDataNode[] nodesTo
+            @SuppressWarnings("ArrayReturn") PathDataNode @Nullable [] nodesFrom,
+            @SuppressWarnings("ArrayReturn") PathDataNode @Nullable [] nodesTo
     ) {
         if (nodesFrom == null || nodesTo == null) {
             return false;
@@ -164,8 +162,8 @@
      * @param source The source path represented in an array of PathDataNode
      */
     public static void updateNodes(
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] target,
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] source
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] target,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] source
     ) {
         for (int i = 0; i < source.length; i++) {
             target[i].mType = source[i].mType;
@@ -318,10 +316,10 @@
      * @see #canMorph(PathDataNode[], PathDataNode[])
      */
     public static void interpolatePathDataNodes(
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] target,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] target,
             float fraction,
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] from,
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] to
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] from,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] to
     ) {
         if (!interpolatePathDataNodes(target, from, to, fraction)) {
             throw new IllegalArgumentException(
@@ -347,9 +345,9 @@
     @Deprecated
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     public static boolean interpolatePathDataNodes(
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] target,
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] from,
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] to,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] target,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] from,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] to,
             float fraction
     ) {
         if (target.length != from.length || from.length != to.length) {
@@ -375,7 +373,7 @@
      */
     @SuppressWarnings("ArrayReturn")
     public static void nodesToPath(
-            @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] node,
+            @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] node,
             @NonNull Path path
     ) {
         float[] current = new float[6];
@@ -406,8 +404,7 @@
             return mType;
         }
 
-        @NonNull
-        public float[] getParams() {
+        public float @NonNull [] getParams() {
             return mParams;
         }
 
@@ -432,7 +429,7 @@
         @RestrictTo(LIBRARY_GROUP_PREFIX)
         @SuppressWarnings("ArrayReturn")
         public static void nodesToPath(
-                @SuppressWarnings("ArrayReturn") @NonNull PathDataNode[] node,
+                @SuppressWarnings("ArrayReturn") PathDataNode @NonNull [] node,
                 @NonNull Path path
         ) {
             PathParser.nodesToPath(node, path);
diff --git a/core/core/src/main/java/androidx/core/graphics/PathSegment.java b/core/core/src/main/java/androidx/core/graphics/PathSegment.java
index a5fe5cb..216d4d3 100644
--- a/core/core/src/main/java/androidx/core/graphics/PathSegment.java
+++ b/core/core/src/main/java/androidx/core/graphics/PathSegment.java
@@ -21,7 +21,7 @@
 import android.graphics.Path;
 import android.graphics.PointF;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * A line segment that represents an approximate fraction of a {@link Path} after
@@ -42,8 +42,7 @@
     }
 
     /** The start point of the line segment. */
-    @NonNull
-    public PointF getStart() {
+    public @NonNull PointF getStart() {
         return mStart;
     }
 
@@ -55,8 +54,7 @@
     }
 
     /** The end point of the line segment. */
-    @NonNull
-    public PointF getEnd() {
+    public @NonNull PointF getEnd() {
         return mEnd;
     }
 
diff --git a/core/core/src/main/java/androidx/core/graphics/PathUtils.java b/core/core/src/main/java/androidx/core/graphics/PathUtils.java
index 043f1c4..e4c5f16 100644
--- a/core/core/src/main/java/androidx/core/graphics/PathUtils.java
+++ b/core/core/src/main/java/androidx/core/graphics/PathUtils.java
@@ -20,9 +20,10 @@
 import android.graphics.PointF;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -40,8 +41,7 @@
      * @see #flatten(Path, float)
      */
     @RequiresApi(26)
-    @NonNull
-    public static Collection<PathSegment> flatten(@NonNull Path path) {
+    public static @NonNull Collection<PathSegment> flatten(@NonNull Path path) {
         return flatten(path, 0.5f);
     }
 
@@ -57,8 +57,7 @@
      * @see Path#approximate
      */
     @RequiresApi(26)
-    @NonNull
-    public static Collection<PathSegment> flatten(@NonNull final Path path,
+    public static @NonNull Collection<PathSegment> flatten(final @NonNull Path path,
             @FloatRange(from = 0) final float error) {
         float[] pathData = Api26Impl.approximate(path, error);
         int pointCount = pathData.length / 3;
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompat.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompat.java
index fcf65ad..c7163d5 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompat.java
@@ -27,8 +27,6 @@
 import android.os.Handler;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
@@ -44,6 +42,9 @@
 import androidx.core.util.Preconditions;
 import androidx.tracing.Trace;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -90,9 +91,8 @@
      *
      * @return null if not found.
      */
-    @Nullable
     @RestrictTo(LIBRARY)
-    public static Typeface findFromCache(@NonNull Resources resources, int id,
+    public static @Nullable Typeface findFromCache(@NonNull Resources resources, int id,
             @Nullable String path, int cookie, int style) {
         return sTypefaceCache.get(createResourceUid(resources, id, path, cookie, style));
     }
@@ -103,10 +103,10 @@
      * @return null if not found.
      * @deprecated Use {@link #findFromCache(Resources, int, String, int, int)} method
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @Deprecated
-    public static Typeface findFromCache(@NonNull Resources resources, int id, int style) {
+    public static @Nullable Typeface findFromCache(@NonNull Resources resources, int id,
+            int style) {
         return findFromCache(resources, id, null, 0, style);
     }
 
@@ -150,13 +150,12 @@
      *
      * @return null if failed to create.
      */
-    @Nullable
     @RestrictTo(LIBRARY)
-    public static Typeface createFromResourcesFamilyXml(
+    public static @Nullable Typeface createFromResourcesFamilyXml(
             @NonNull Context context, @NonNull FamilyResourceEntry entry,
             @NonNull Resources resources, int id, @Nullable String path,
             int cookie, int style,
-            @Nullable ResourcesCompat.FontCallback fontCallback, @Nullable Handler handler,
+            ResourcesCompat.@Nullable FontCallback fontCallback, @Nullable Handler handler,
             boolean isRequestFromLayoutInflator) {
         Typeface typeface;
         if (entry instanceof ProviderResourceEntry) {
@@ -215,13 +214,12 @@
      * @deprecated Use {@link #createFromResourcesFamilyXml(Context, FamilyResourceEntry,
      * Resources, int, String, int, int, ResourcesCompat.FontCallback, Handler, boolean)} method
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @Deprecated
-    public static Typeface createFromResourcesFamilyXml(
+    public static @Nullable Typeface createFromResourcesFamilyXml(
             @NonNull Context context, @NonNull FamilyResourceEntry entry,
             @NonNull Resources resources, int id, int style,
-            @Nullable ResourcesCompat.FontCallback fontCallback, @Nullable Handler handler,
+            ResourcesCompat.@Nullable FontCallback fontCallback, @Nullable Handler handler,
             boolean isRequestFromLayoutInflator) {
         return createFromResourcesFamilyXml(context, entry, resources, id, null, 0, style,
                 fontCallback, handler, isRequestFromLayoutInflator);
@@ -230,9 +228,8 @@
     /**
      * Used by Resources to load a font resource of type font file.
      */
-    @Nullable
     @RestrictTo(LIBRARY)
-    public static Typeface createFromResourcesFontFile(
+    public static @Nullable Typeface createFromResourcesFontFile(
             @NonNull Context context, @NonNull Resources resources, int id, String path, int cookie,
             int style) {
         Typeface typeface = sTypefaceCompatImpl.createFromResourcesFontFile(
@@ -249,10 +246,9 @@
      * @deprecated Use {@link #createFromResourcesFontFile(Context, Resources, int, String,
      * int, int)} method
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @Deprecated
-    public static Typeface createFromResourcesFontFile(
+    public static @Nullable Typeface createFromResourcesFontFile(
             @NonNull Context context, @NonNull Resources resources, int id, String path,
             int style) {
         return createFromResourcesFontFile(context, resources, id, path, 0, style);
@@ -261,10 +257,10 @@
     /**
      * Create a Typeface from a given FontInfo list and a map that matches them to ByteBuffers.
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public static Typeface createFromFontInfo(@NonNull Context context,
-            @Nullable CancellationSignal cancellationSignal, @NonNull FontInfo[] fonts, int style) {
+    public static @Nullable Typeface createFromFontInfo(@NonNull Context context,
+            @Nullable CancellationSignal cancellationSignal, FontInfo @NonNull [] fonts,
+            int style) {
         if (TypefaceCompat.DOWNLOADABLE_FONT_TRACING) {
             Trace.beginSection("TypefaceCompat.createFromFontInfo");
         }
@@ -283,10 +279,9 @@
      * <p>
      * This currently throws an exception if used below API 29.
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @RequiresApi(29)
-    public static Typeface createFromFontInfoWithFallback(@NonNull Context context,
+    public static @Nullable Typeface createFromFontInfoWithFallback(@NonNull Context context,
             @Nullable CancellationSignal cancellationSignal, @NonNull List<FontInfo[]> fonts,
             int style) {
         if (TypefaceCompat.DOWNLOADABLE_FONT_TRACING) {
@@ -306,9 +301,8 @@
     /**
      * Retrieves the best matching font from the family specified by the {@link Typeface} object
      */
-    @Nullable
-    private static Typeface getBestFontFromFamily(final Context context, final Typeface typeface,
-            final int style) {
+    private static @Nullable Typeface getBestFontFromFamily(final Context context,
+            final Typeface typeface, final int style) {
         final FontFamilyFilesResourceEntry families = sTypefaceCompatImpl.getFontFamily(typeface);
         if (families == null) {
             return null;
@@ -327,9 +321,8 @@
      * @param context The context used to retrieve the font.
      * @return The best matching typeface.
      */
-    @NonNull
-    public static Typeface create(@NonNull final Context context, @Nullable final Typeface family,
-            final int style) {
+    public static @NonNull Typeface create(final @NonNull Context context,
+            final @Nullable Typeface family, final int style) {
         if (context == null) {
             throw new IllegalArgumentException("Context cannot be null");
         }
@@ -381,8 +374,7 @@
      * @see Typeface#getWeight()
      * @see Typeface#isItalic()
      */
-    @NonNull
-    public static Typeface create(@NonNull Context context, @Nullable Typeface family,
+    public static @NonNull Typeface create(@NonNull Context context, @Nullable Typeface family,
             @IntRange(from = 1, to = 1000) int weight, boolean italic) {
         if (context == null) {
             throw new IllegalArgumentException("Context cannot be null");
@@ -412,10 +404,9 @@
      */
     @RestrictTo(LIBRARY)
     public static class ResourcesCallbackAdapter extends FontsContractCompat.FontRequestCallback {
-        @Nullable
-        private ResourcesCompat.FontCallback mFontCallback;
+        private ResourcesCompat.@Nullable FontCallback mFontCallback;
 
-        public ResourcesCallbackAdapter(@Nullable ResourcesCompat.FontCallback fontCallback) {
+        public ResourcesCallbackAdapter(ResourcesCompat.@Nullable FontCallback fontCallback) {
             mFontCallback = fontCallback;
         }
 
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi21Impl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi21Impl.java
index 0a7a7bb..dcd0f4939 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi21Impl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi21Impl.java
@@ -29,13 +29,14 @@
 import android.system.OsConstants;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.FontResourcesParserCompat.FontFamilyFilesResourceEntry;
 import androidx.core.content.res.FontResourcesParserCompat.FontFileResourceEntry;
 import androidx.core.provider.FontsContractCompat.FontInfo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
@@ -44,7 +45,6 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 
-
 /**
  * Implementation of the Typeface compat methods for API 21 and above.
  */
@@ -144,7 +144,7 @@
 
     @Override
     public Typeface createFromFontInfo(Context context, CancellationSignal cancellationSignal,
-            @NonNull FontInfo[] fonts, int style) {
+            FontInfo @NonNull [] fonts, int style) {
         if (fonts.length < 1) {
             return null;
         }
@@ -198,9 +198,8 @@
         return createFromFamiliesWithDefault(family);
     }
 
-    @NonNull
     @Override
-    Typeface createWeightStyle(@NonNull Context context,
+    @NonNull Typeface createWeightStyle(@NonNull Context context,
             @NonNull Typeface base, int weight, boolean italic) {
         Typeface out = null;
         try {
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi24Impl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi24Impl.java
index 7e1b656..0e37d61 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi24Impl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi24Impl.java
@@ -25,8 +25,6 @@
 import android.os.CancellationSignal;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.collection.SimpleArrayMap;
@@ -34,6 +32,9 @@
 import androidx.core.content.res.FontResourcesParserCompat.FontFileResourceEntry;
 import androidx.core.provider.FontsContractCompat.FontInfo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Array;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
@@ -41,7 +42,6 @@
 import java.nio.ByteBuffer;
 import java.util.List;
 
-
 /**
  * Implementation of the Typeface compat methods for API 24 and above.
  */
@@ -128,9 +128,9 @@
     }
 
     @Override
-    @Nullable
-    public Typeface createFromFontInfo(Context context,
-            @Nullable CancellationSignal cancellationSignal, @NonNull FontInfo[] fonts, int style) {
+    public @Nullable Typeface createFromFontInfo(Context context,
+            @Nullable CancellationSignal cancellationSignal, FontInfo @NonNull [] fonts,
+            int style) {
         Object family = newFamily();
         if (family == null) {
             return null;
@@ -160,8 +160,7 @@
     }
 
     @Override
-    @Nullable
-    public Typeface createFromFontFamilyFilesResourceEntry(Context context,
+    public @Nullable Typeface createFromFontFamilyFilesResourceEntry(Context context,
             FontFamilyFilesResourceEntry entry, Resources resources, int style) {
         Object family = newFamily();
         if (family == null) {
@@ -180,9 +179,8 @@
         return createFromFamiliesWithDefault(family);
     }
 
-    @NonNull
     @Override
-    Typeface createWeightStyle(@NonNull Context context,
+    @NonNull Typeface createWeightStyle(@NonNull Context context,
             @NonNull Typeface base, int weight, boolean italic) {
         Typeface out = null;
         try {
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi26Impl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi26Impl.java
index b1353688..955ef6f 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi26Impl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi26Impl.java
@@ -29,14 +29,15 @@
 import android.os.ParcelFileDescriptor;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.FontResourcesParserCompat;
 import androidx.core.content.res.FontResourcesParserCompat.FontFileResourceEntry;
 import androidx.core.provider.FontsContractCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.IOException;
 import java.lang.reflect.Array;
 import java.lang.reflect.Constructor;
@@ -120,8 +121,7 @@
     /**
      * Create a new FontFamily instance
      */
-    @Nullable
-    private Object newFamily() {
+    private @Nullable Object newFamily() {
         try {
             return mFontFamilyCtor.newInstance();
         } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
@@ -134,7 +134,7 @@
      *      boolean isAsset, int ttcIndex, int weight, int isItalic, FontVariationAxis[] axes)
      */
     private boolean addFontFromAssetManager(Context context, Object family, String fileName,
-            int ttcIndex, int weight, int style, @Nullable FontVariationAxis[] axes) {
+            int ttcIndex, int weight, int style, FontVariationAxis @Nullable [] axes) {
         try {
             return (Boolean) mAddFontFromAssetManager.invoke(family,
                     context.getAssets(), fileName, 0 /* cookie */, false /* isAsset */, ttcIndex,
@@ -162,8 +162,7 @@
      * Call method Typeface#createFromFamiliesWithDefault(
      *      FontFamily[] families, int weight, int italic)
      */
-    @Nullable
-    protected Typeface createFromFamiliesWithDefault(Object family) {
+    protected @Nullable Typeface createFromFamiliesWithDefault(Object family) {
         try {
             Object familyArray = Array.newInstance(mFontFamily, 1);
             Array.set(familyArray, 0, family);
@@ -195,8 +194,7 @@
     }
 
     @Override
-    @Nullable
-    public Typeface createFromFontFamilyFilesResourceEntry(Context context,
+    public @Nullable Typeface createFromFontFamilyFilesResourceEntry(Context context,
             FontResourcesParserCompat.FontFamilyFilesResourceEntry entry, Resources resources,
             int style) {
         if (!isFontFamilyPrivateAPIAvailable()) {
@@ -221,10 +219,9 @@
     }
 
     @Override
-    @Nullable
-    public Typeface createFromFontInfo(Context context,
+    public @Nullable Typeface createFromFontInfo(Context context,
             @Nullable CancellationSignal cancellationSignal,
-            @NonNull FontsContractCompat.FontInfo[] fonts, int style) {
+            FontsContractCompat.FontInfo @NonNull [] fonts, int style) {
         if (fonts.length < 1) {
             return null;
         }
@@ -283,9 +280,8 @@
     /**
      * Used by Resources to load a font resource of type font file.
      */
-    @Nullable
     @Override
-    public Typeface createFromResourcesFontFile(
+    public @Nullable Typeface createFromResourcesFontFile(
             Context context, Resources resources, int id, String path, int style) {
         if (!isFontFamilyPrivateAPIAvailable()) {
             return super.createFromResourcesFontFile(context, resources, id, path, style);
@@ -351,9 +347,8 @@
         return m;
     }
 
-    @NonNull
     @Override
-    Typeface createWeightStyle(@NonNull Context context,
+    @NonNull Typeface createWeightStyle(@NonNull Context context,
             @NonNull Typeface base, int weight, boolean italic) {
         Typeface out = null;
         try {
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi28Impl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi28Impl.java
index 18f4d17..c4d90cf 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi28Impl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi28Impl.java
@@ -21,10 +21,11 @@
 import android.content.Context;
 import android.graphics.Typeface;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Array;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -66,9 +67,8 @@
         return m;
     }
 
-    @NonNull
     @Override
-    Typeface createWeightStyle(@NonNull Context context,
+    @NonNull Typeface createWeightStyle(@NonNull Context context,
             @NonNull Typeface base, int weight, boolean italic) {
         return Typeface.create(base, weight, italic);
     }
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi29Impl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi29Impl.java
index ff8d800..5990c9e 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi29Impl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatApi29Impl.java
@@ -29,13 +29,14 @@
 import android.os.ParcelFileDescriptor;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.FontResourcesParserCompat;
 import androidx.core.provider.FontsContractCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
@@ -83,11 +84,10 @@
         throw new RuntimeException("Do not use this function in API 29 or later.");
     }
 
-    @Nullable
     @Override
-    public Typeface createFromFontInfo(Context context,
+    public @Nullable Typeface createFromFontInfo(Context context,
             @Nullable CancellationSignal cancellationSignal,
-            @NonNull FontsContractCompat.FontInfo[] fonts, int style) {
+            FontsContractCompat.FontInfo @NonNull [] fonts, int style) {
         final ContentResolver resolver = context.getContentResolver();
         try {
             final FontFamily family = getFontFamily(cancellationSignal, fonts, resolver);
@@ -103,7 +103,7 @@
 
     private static @Nullable FontFamily getFontFamily(
             @Nullable CancellationSignal cancellationSignal,
-            @NonNull FontsContractCompat.FontInfo[] fonts, ContentResolver resolver) {
+            FontsContractCompat.FontInfo @NonNull [] fonts, ContentResolver resolver) {
         FontFamily.Builder familyBuilder = null;
         for (FontsContractCompat.FontInfo font : fonts) {
             try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(font.getUri(), "r",
@@ -134,9 +134,8 @@
         return family;
     }
 
-    @Nullable
     @Override
-    public Typeface createFromFontInfoWithFallback(@NonNull Context context,
+    public @Nullable Typeface createFromFontInfoWithFallback(@NonNull Context context,
             @Nullable CancellationSignal cancellationSignal,
             @NonNull List<FontsContractCompat.FontInfo[]> fonts, int style) {
         final ContentResolver resolver = context.getContentResolver();
@@ -163,9 +162,8 @@
         }
     }
 
-    @Nullable
     @Override
-    public Typeface createFromFontFamilyFilesResourceEntry(Context context,
+    public @Nullable Typeface createFromFontFamilyFilesResourceEntry(Context context,
             FontResourcesParserCompat.FontFamilyFilesResourceEntry familyEntry, Resources resources,
             int style) {
         try {
@@ -204,9 +202,8 @@
     /**
      * Used by Resources to load a font resource of type font file.
      */
-    @Nullable
     @Override
-    public Typeface createFromResourcesFontFile(
+    public @Nullable Typeface createFromResourcesFontFile(
             Context context, Resources resources, int id, String path, int style) {
         FontFamily family = null;
         Font font = null;
@@ -223,9 +220,8 @@
         }
     }
 
-    @NonNull
     @Override
-    Typeface createWeightStyle(@NonNull Context context,
+    @NonNull Typeface createWeightStyle(@NonNull Context context,
             @NonNull Typeface base, int weight, boolean italic) {
         return Typeface.create(base, weight, italic);
     }
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatBaseImpl.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatBaseImpl.java
index 15e4e20..af49e70 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatBaseImpl.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatBaseImpl.java
@@ -25,14 +25,15 @@
 import android.os.CancellationSignal;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.FontResourcesParserCompat.FontFamilyFilesResourceEntry;
 import androidx.core.content.res.FontResourcesParserCompat.FontFileResourceEntry;
 import androidx.core.provider.FontsContractCompat.FontInfo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -83,7 +84,7 @@
         return best;
     }
 
-    private static long getUniqueKey(@Nullable final Typeface typeface) {
+    private static long getUniqueKey(final @Nullable Typeface typeface) {
         if (typeface == null) {
             return INVALID_KEY;
         }
@@ -137,9 +138,9 @@
         }
     }
 
-    @Nullable
-    public Typeface createFromFontInfo(Context context,
-            @Nullable CancellationSignal cancellationSignal, @NonNull FontInfo[] fonts, int style) {
+    public @Nullable Typeface createFromFontInfo(Context context,
+            @Nullable CancellationSignal cancellationSignal, FontInfo @NonNull [] fonts,
+            int style) {
         // When we load from file, we can only load one font so just take the first one.
         if (fonts.length < 1) {
             return null;
@@ -156,9 +157,8 @@
         }
     }
 
-    @Nullable
     @RequiresApi(29)
-    public Typeface createFromFontInfoWithFallback(@NonNull Context context,
+    public @Nullable Typeface createFromFontInfoWithFallback(@NonNull Context context,
             @Nullable CancellationSignal cancellationSignal,
             @NonNull List<FontInfo[]> fonts, int style) {
         throw new IllegalStateException(
@@ -195,8 +195,7 @@
                 });
     }
 
-    @Nullable
-    public Typeface createFromFontFamilyFilesResourceEntry(Context context,
+    public @Nullable Typeface createFromFontFamilyFilesResourceEntry(Context context,
             FontFamilyFilesResourceEntry entry, Resources resources, int style) {
         FontFileResourceEntry best = findBestEntry(entry, style);
         if (best == null) {
@@ -210,8 +209,7 @@
         return typeface;
     }
 
-    @Nullable
-    Typeface createFromFontFamilyFilesResourceEntry(Context context,
+    @Nullable Typeface createFromFontFamilyFilesResourceEntry(Context context,
             FontFamilyFilesResourceEntry entry, Resources resources, int weight, boolean italic) {
         FontFileResourceEntry best = findBestEntry(entry, weight, italic);
         if (best == null) {
@@ -228,8 +226,7 @@
     /**
      * Used by Resources to load a font resource of type font file.
      */
-    @Nullable
-    public Typeface createFromResourcesFontFile(
+    public @Nullable Typeface createFromResourcesFontFile(
             Context context, Resources resources, int id, String path, int style) {
         final File tmpFile = TypefaceCompatUtil.getTempFile(context);
         if (tmpFile == null) {
@@ -250,8 +247,7 @@
         }
     }
 
-    @NonNull
-    Typeface createWeightStyle(@NonNull Context context, @NonNull Typeface base,
+    @NonNull Typeface createWeightStyle(@NonNull Context context, @NonNull Typeface base,
             int weight, boolean italic) {
         Typeface out = null;
         try {
@@ -268,8 +264,7 @@
     /**
      * Retrieves the font family resource entries given a unique identifier for a Typeface
      */
-    @Nullable
-    FontFamilyFilesResourceEntry getFontFamily(final Typeface typeface) {
+    @Nullable FontFamilyFilesResourceEntry getFontFamily(final Typeface typeface) {
         final long key = getUniqueKey(typeface);
         if (key == INVALID_KEY) {
             return null;
diff --git a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatUtil.java b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatUtil.java
index 710ef80f..d66858b 100644
--- a/core/core/src/main/java/androidx/core/graphics/TypefaceCompatUtil.java
+++ b/core/core/src/main/java/androidx/core/graphics/TypefaceCompatUtil.java
@@ -29,11 +29,12 @@
 import android.os.StrictMode;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.provider.FontsContractCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.Closeable;
 import java.io.File;
 import java.io.FileInputStream;
@@ -62,8 +63,7 @@
      *
      * Returns null if failed to create temp file.
      */
-    @Nullable
-    public static File getTempFile(@NonNull Context context) {
+    public static @Nullable File getTempFile(@NonNull Context context) {
         File cacheDir = context.getCacheDir();
         if (cacheDir == null) {
             return null;
@@ -86,8 +86,7 @@
     /**
      * Copy the file contents to the direct byte buffer.
      */
-    @Nullable
-    private static ByteBuffer mmap(File file) {
+    private static @Nullable ByteBuffer mmap(File file) {
         try (FileInputStream fis = new FileInputStream(file)) {
             FileChannel channel = fis.getChannel();
             final long size = channel.size();
@@ -100,8 +99,7 @@
     /**
      * Copy the file contents to the direct byte buffer.
      */
-    @Nullable
-    public static ByteBuffer mmap(@NonNull Context context,
+    public static @Nullable ByteBuffer mmap(@NonNull Context context,
             @Nullable CancellationSignal cancellationSignal, @NonNull Uri uri) {
         final ContentResolver resolver = context.getContentResolver();
         try {
@@ -125,9 +123,8 @@
      * Copy the resource contents to the direct byte buffer.
      */
     @SuppressWarnings("ResultOfMethodCallIgnored")
-    @Nullable
-    public static ByteBuffer copyToDirectBuffer(@NonNull Context context, @NonNull Resources res,
-            int id) {
+    public static @Nullable ByteBuffer copyToDirectBuffer(@NonNull Context context,
+            @NonNull Resources res, int id) {
         File tmpFile = getTempFile(context);
         if (tmpFile == null) {
             return null;
@@ -205,10 +202,9 @@
      * @return A map from {@link Uri} to {@link ByteBuffer}.
      */
     @RestrictTo(LIBRARY)
-    @NonNull
-    public static Map<Uri, ByteBuffer> readFontInfoIntoByteBuffer(
+    public static @NonNull Map<Uri, ByteBuffer> readFontInfoIntoByteBuffer(
             @NonNull Context context,
-            @NonNull FontsContractCompat.FontInfo[] fonts,
+            FontsContractCompat.FontInfo @NonNull [] fonts,
             @Nullable CancellationSignal cancellationSignal
     ) {
         final HashMap<Uri, ByteBuffer> out = new HashMap<>();
diff --git a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi14.java b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi14.java
index 90298ca..01606cb 100644
--- a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi14.java
+++ b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi14.java
@@ -24,12 +24,13 @@
 import android.util.SparseArray;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.collection.LongSparseArray;
 import androidx.core.content.res.FontResourcesParserCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Field;
 
 /**
@@ -74,8 +75,7 @@
     /**
      * @return Valid typeface, or {@code null} if private API is not available
      */
-    @Nullable
-    static Typeface createWeightStyle(@NonNull TypefaceCompatBaseImpl compat,
+    static @Nullable Typeface createWeightStyle(@NonNull TypefaceCompatBaseImpl compat,
             @NonNull Context context, @NonNull Typeface base, int weight, boolean italic) {
         if (!isPrivateApiAvailable()) {
             return null;
@@ -128,8 +128,7 @@
     /**
      * @see {@code TypefaceCompat#getBestFontFromFamily(Context, Typeface, int)}
      */
-    @Nullable
-    private static Typeface getBestFontFromFamily(@NonNull TypefaceCompatBaseImpl compat,
+    private static @Nullable Typeface getBestFontFromFamily(@NonNull TypefaceCompatBaseImpl compat,
             @NonNull Context context, @NonNull Typeface base, int weight, boolean italic) {
         final FontResourcesParserCompat.FontFamilyFilesResourceEntry family =
                 compat.getFontFamily(base);
diff --git a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi21.java b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi21.java
index b07f0ae..6eb24a6 100644
--- a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi21.java
+++ b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi21.java
@@ -24,12 +24,13 @@
 import android.util.SparseArray;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.collection.LongSparseArray;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -99,8 +100,8 @@
     /**
      * @return Valid typeface, or {@code null} if private API is not available
      */
-    @Nullable
-    static Typeface createWeightStyle(@NonNull Typeface base, int weight, boolean italic) {
+    static @Nullable Typeface createWeightStyle(@NonNull Typeface base, int weight,
+            boolean italic) {
         if (!isPrivateApiAvailable()) {
             return null;
         }
@@ -170,8 +171,7 @@
         }
     }
 
-    @Nullable
-    private static Typeface create(long nativeInstance) {
+    private static @Nullable Typeface create(long nativeInstance) {
         try {
             return sConstructor.newInstance(nativeInstance);
         } catch (IllegalAccessException e) {
diff --git a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi26.java b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi26.java
index 2a8d51f..3602562 100644
--- a/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi26.java
+++ b/core/core/src/main/java/androidx/core/graphics/WeightTypefaceApi26.java
@@ -24,12 +24,13 @@
 import android.util.SparseArray;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.collection.LongSparseArray;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -93,8 +94,8 @@
     /**
      * @return Valid typeface, or {@code null} if private API is not available
      */
-    @Nullable
-    static Typeface createWeightStyle(@NonNull Typeface base, int weight, boolean italic) {
+    static @Nullable Typeface createWeightStyle(@NonNull Typeface base, int weight,
+            boolean italic) {
         if (!isPrivateApiAvailable()) {
             return null;
         }
@@ -144,8 +145,7 @@
         }
     }
 
-    @Nullable
-    private static Typeface create(long nativeInstance) {
+    private static @Nullable Typeface create(long nativeInstance) {
         try {
             return sConstructor.newInstance(nativeInstance);
         } catch (IllegalAccessException e) {
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/DrawableCompat.java b/core/core/src/main/java/androidx/core/graphics/drawable/DrawableCompat.java
index 4d4ad3b..67b7675 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/DrawableCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/DrawableCompat.java
@@ -29,11 +29,11 @@
 import android.view.View;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.view.ViewCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -166,7 +166,7 @@
      * @param drawable The Drawable against which to invoke the method.
      * @param tintMode A Porter-Duff blending mode
      */
-    public static void setTintMode(@NonNull Drawable drawable, @Nullable PorterDuff.Mode tintMode) {
+    public static void setTintMode(@NonNull Drawable drawable, PorterDuff.@Nullable Mode tintMode) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.setTintMode(drawable, tintMode);
         } else if (drawable instanceof TintAwareDrawable) {
@@ -192,7 +192,7 @@
      * Applies the specified theme to this Drawable and its children.
      */
     @SuppressWarnings("unused")
-    public static void applyTheme(@NonNull Drawable drawable, @NonNull Resources.Theme theme) {
+    public static void applyTheme(@NonNull Drawable drawable, Resources.@NonNull Theme theme) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.applyTheme(drawable, theme);
         }
@@ -216,8 +216,7 @@
      * @return the current color filter, or {@code null} if none set
      */
     @SuppressWarnings("unused")
-    @Nullable
-    public static ColorFilter getColorFilter(@NonNull Drawable drawable) {
+    public static @Nullable ColorFilter getColorFilter(@NonNull Drawable drawable) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getColorFilter(drawable);
         } else {
@@ -276,7 +275,7 @@
      */
     public static void inflate(@NonNull Drawable drawable, @NonNull Resources res,
             @NonNull XmlPullParser parser, @NonNull AttributeSet attrs,
-            @Nullable Resources.Theme theme)
+            Resources.@Nullable Theme theme)
             throws XmlPullParserException, IOException {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.inflate(drawable, res, parser, attrs, theme);
@@ -316,8 +315,7 @@
      * @see #setTintMode(Drawable, PorterDuff.Mode)
      * @see #unwrap(Drawable)
      */
-    @NonNull
-    public static Drawable wrap(@NonNull Drawable drawable) {
+    public static @NonNull Drawable wrap(@NonNull Drawable drawable) {
         if (Build.VERSION.SDK_INT >= 23) {
             return drawable;
         } else if (Build.VERSION.SDK_INT >= 21) {
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/IconCompat.java b/core/core/src/main/java/androidx/core/graphics/drawable/IconCompat.java
index 881d8ec..bc9d297 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/IconCompat.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/IconCompat.java
@@ -52,8 +52,6 @@
 import androidx.annotation.DrawableRes;
 import androidx.annotation.IdRes;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
@@ -66,6 +64,9 @@
 import androidx.versionedparcelable.ParcelField;
 import androidx.versionedparcelable.VersionedParcelize;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileInputStream;
@@ -169,16 +170,14 @@
 
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @ParcelField(value = 2, defaultValue = "null")
-    public byte[]          mData = null;
+    public byte @Nullable []          mData = null;
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @ParcelField(value = 3, defaultValue = "null")
-    public Parcelable      mParcelable = null;
+    public @Nullable Parcelable      mParcelable = null;
 
     // TYPE_RESOURCE: resId
     // TYPE_DATA: data offset
@@ -197,27 +196,24 @@
 
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @ParcelField(value = 6, defaultValue = "null")
-    public ColorStateList  mTintList = null;
+    public @Nullable ColorStateList  mTintList = null;
 
     static final PorterDuff.Mode DEFAULT_TINT_MODE = PorterDuff.Mode.SRC_IN; // SRC_IN
     @NonParcelField
     PorterDuff.Mode mTintMode = DEFAULT_TINT_MODE;
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @ParcelField(value = 7, defaultValue = "null")
-    public String mTintModeStr = null;
+    public @Nullable String mTintModeStr = null;
 
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY)
     @ParcelField(value = 8, defaultValue = "null")
-    public String mString1;
+    public @Nullable String mString1;
 
     /**
      * Create an Icon pointing to a drawable resource.
@@ -226,17 +222,17 @@
      * @param resId ID of the drawable resource
      * @see android.graphics.drawable.Icon#createWithResource(Context, int)
      */
-    @NonNull
-    public static IconCompat createWithResource(@NonNull Context context, @DrawableRes int resId) {
+    public static @NonNull IconCompat createWithResource(@NonNull Context context,
+            @DrawableRes int resId) {
         ObjectsCompat.requireNonNull(context);
         return createWithResource(context.getResources(), context.getPackageName(), resId);
     }
 
     /**
      */
-    @NonNull
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public static IconCompat createWithResource(@Nullable Resources r, @NonNull String pkg,
+    public static @NonNull IconCompat createWithResource(@Nullable Resources r,
+            @NonNull String pkg,
             @DrawableRes int resId) {
         ObjectsCompat.requireNonNull(pkg);
         if (resId == 0) {
@@ -262,8 +258,7 @@
      * @param bits A valid {@link android.graphics.Bitmap} object
      * @see android.graphics.drawable.Icon#createWithBitmap(Bitmap)
      */
-    @NonNull
-    public static IconCompat createWithBitmap(@NonNull Bitmap bits) {
+    public static @NonNull IconCompat createWithBitmap(@NonNull Bitmap bits) {
         ObjectsCompat.requireNonNull(bits);
         final IconCompat rep = new IconCompat(TYPE_BITMAP);
         rep.mObj1 = bits;
@@ -276,8 +271,7 @@
      * @param bits A valid {@link android.graphics.Bitmap} object
      * @see android.graphics.drawable.Icon#createWithAdaptiveBitmap(Bitmap)
      */
-    @NonNull
-    public static IconCompat createWithAdaptiveBitmap(@NonNull Bitmap bits) {
+    public static @NonNull IconCompat createWithAdaptiveBitmap(@NonNull Bitmap bits) {
         ObjectsCompat.requireNonNull(bits);
         final IconCompat rep = new IconCompat(TYPE_ADAPTIVE_BITMAP);
         rep.mObj1 = bits;
@@ -293,8 +287,8 @@
      * @param length Length of the bitmap data
      * @see android.graphics.drawable.Icon#createWithData(byte[], int, int)
      */
-    @NonNull
-    public static IconCompat createWithData(@NonNull byte[] data, int offset, int length) {
+    public static @NonNull IconCompat createWithData(byte @NonNull [] data, int offset,
+            int length) {
         ObjectsCompat.requireNonNull(data);
         final IconCompat rep = new IconCompat(TYPE_DATA);
         rep.mObj1 = data;
@@ -309,8 +303,7 @@
      * @param uri A uri referring to local content:// or file:// image data.
      * @see android.graphics.drawable.Icon#createWithContentUri(String)
      */
-    @NonNull
-    public static IconCompat createWithContentUri(@NonNull String uri) {
+    public static @NonNull IconCompat createWithContentUri(@NonNull String uri) {
         ObjectsCompat.requireNonNull(uri);
         final IconCompat rep = new IconCompat(TYPE_URI);
         rep.mObj1 = uri;
@@ -323,8 +316,7 @@
      * @param uri A uri referring to local content:// or file:// image data.
      * @see android.graphics.drawable.Icon#createWithContentUri(String)
      */
-    @NonNull
-    public static IconCompat createWithContentUri(@NonNull Uri uri) {
+    public static @NonNull IconCompat createWithContentUri(@NonNull Uri uri) {
         ObjectsCompat.requireNonNull(uri);
         return createWithContentUri(uri.toString());
     }
@@ -336,8 +328,7 @@
      * @param uri A uri referring to local content:// or file:// image data.
      * @see android.graphics.drawable.Icon#createWithAdaptiveBitmapContentUri(String)
      */
-    @NonNull
-    public static IconCompat createWithAdaptiveBitmapContentUri(@NonNull String uri) {
+    public static @NonNull IconCompat createWithAdaptiveBitmapContentUri(@NonNull String uri) {
         ObjectsCompat.requireNonNull(uri);
         final IconCompat rep = new IconCompat(TYPE_URI_ADAPTIVE_BITMAP);
         rep.mObj1 = uri;
@@ -351,8 +342,7 @@
      * @param uri A uri referring to local content:// or file:// image data.
      * @see android.graphics.drawable.Icon#createWithAdaptiveBitmapContentUri(String)
      */
-    @NonNull
-    public static IconCompat createWithAdaptiveBitmapContentUri(@NonNull Uri uri) {
+    public static @NonNull IconCompat createWithAdaptiveBitmapContentUri(@NonNull Uri uri) {
         ObjectsCompat.requireNonNull(uri);
         return createWithAdaptiveBitmapContentUri(uri.toString());
     }
@@ -389,8 +379,7 @@
      * Note: This package may not be available if referenced in the future, and it is
      * up to the caller to ensure safety if this package is re-used and/or persisted.
      */
-    @NonNull
-    public String getResPackage() {
+    public @NonNull String getResPackage() {
         if (mType == TYPE_UNKNOWN && Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getResPackage(mObj1);
         }
@@ -436,8 +425,7 @@
      *
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public Bitmap getBitmap() {
+    public @Nullable Bitmap getBitmap() {
         if (mType == TYPE_UNKNOWN && Build.VERSION.SDK_INT >= 23) {
             if (mObj1 instanceof Bitmap) {
                 return (Bitmap) mObj1;
@@ -460,8 +448,7 @@
      * Note: This uri may not be available in the future, and it is
      * up to the caller to ensure safety if this uri is re-used and/or persisted.
      */
-    @NonNull
-    public Uri getUri() {
+    public @NonNull Uri getUri() {
         if (mType == TYPE_UNKNOWN && Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getUri(mObj1);
         }
@@ -477,8 +464,7 @@
      * @param tint a color, as in {@link Drawable#setTint(int)}
      * @return this same object, for use in chained construction
      */
-    @NonNull
-    public IconCompat setTint(@ColorInt int tint) {
+    public @NonNull IconCompat setTint(@ColorInt int tint) {
         return setTintList(ColorStateList.valueOf(tint));
     }
 
@@ -488,8 +474,7 @@
      * @param tintList as in {@link Drawable#setTintList(ColorStateList)}, null to remove tint
      * @return this same object, for use in chained construction
      */
-    @NonNull
-    public IconCompat setTintList(@Nullable ColorStateList tintList) {
+    public @NonNull IconCompat setTintList(@Nullable ColorStateList tintList) {
         mTintList = tintList;
         return this;
     }
@@ -500,8 +485,7 @@
      * @param mode a blending mode, as in {@link Drawable#setTintMode(PorterDuff.Mode)}, may be null
      * @return this same object, for use in chained construction
      */
-    @NonNull
-    public IconCompat setTintMode(@Nullable PorterDuff.Mode mode) {
+    public @NonNull IconCompat setTintMode(PorterDuff.@Nullable Mode mode) {
         mTintMode = mode;
         return this;
     }
@@ -511,8 +495,7 @@
      */
     @RequiresApi(23)
     @Deprecated
-    @NonNull
-    public Icon toIcon() {
+    public @NonNull Icon toIcon() {
         return toIcon(null);
     }
 
@@ -522,8 +505,7 @@
      * @return {@link Icon} object
      */
     @RequiresApi(23)
-    @NonNull
-    public Icon toIcon(@Nullable Context context) {
+    public @NonNull Icon toIcon(@Nullable Context context) {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.toIcon(this, context);
         } else {
@@ -570,8 +552,7 @@
      *                to access {@link android.content.res.Resources Resources}, for example.
      * @return A fresh instance of a drawable for this image, yours to keep.
      */
-    @Nullable
-    public Drawable loadDrawable(@NonNull Context context) {
+    public @Nullable Drawable loadDrawable(@NonNull Context context) {
         checkResource(context);
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.loadDrawable(toIcon(context), context);
@@ -645,9 +626,8 @@
      * Create an input stream for bitmap by resolving corresponding content uri.
      *
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP)
-    public InputStream getUriInputStream(@NonNull Context context) {
+    public @Nullable InputStream getUriInputStream(@NonNull Context context) {
         final Uri uri = getUri();
         final String scheme = uri.getScheme();
         if (ContentResolver.SCHEME_CONTENT.equals(scheme)
@@ -751,8 +731,7 @@
      * Adds this Icon to a Bundle that can be read back with the same parameters
      * to {@link #createFromBundle(Bundle)}.
      */
-    @NonNull
-    public Bundle toBundle() {
+    public @NonNull Bundle toBundle() {
         Bundle bundle = new Bundle();
         switch (mType) {
             case TYPE_BITMAP:
@@ -787,9 +766,8 @@
         return bundle;
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         if (mType == TYPE_UNKNOWN) {
             return String.valueOf(mObj1);
         }
@@ -964,8 +942,8 @@
      * Creates an IconCompat from an Icon.
      */
     @RequiresApi(23)
-    @Nullable
-    public static IconCompat createFromIcon(@NonNull Context context, @NonNull Icon icon) {
+    public static @Nullable IconCompat createFromIcon(@NonNull Context context,
+            @NonNull Icon icon) {
         Preconditions.checkNotNull(icon);
         return Api23Impl.createFromIcon(context, icon);
     }
@@ -975,8 +953,7 @@
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @RequiresApi(23)
-    @Nullable
-    public static IconCompat createFromIcon(@NonNull Icon icon) {
+    public static @Nullable IconCompat createFromIcon(@NonNull Icon icon) {
         return Api23Impl.createFromIconInner(icon);
     }
 
@@ -986,8 +963,7 @@
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     @RequiresApi(23)
-    @Nullable
-    public static IconCompat createFromIconOrNullIfZeroResId(@NonNull Icon icon) {
+    public static @Nullable IconCompat createFromIconOrNullIfZeroResId(@NonNull Icon icon) {
         if (Api23Impl.getType(icon) == TYPE_RESOURCE && Api23Impl.getResId(icon) == 0) {
             return null;
         }
@@ -1098,8 +1074,7 @@
             // This class is not instantiable.
         }
 
-        @Nullable
-        static IconCompat createFromIcon(@NonNull Context context, @NonNull Icon icon) {
+        static @Nullable IconCompat createFromIcon(@NonNull Context context, @NonNull Icon icon) {
             switch (getType(icon)) {
                 case TYPE_RESOURCE:
                     String resPackage = getResPackage(icon);
@@ -1154,8 +1129,7 @@
          * up to the caller to ensure safety if this package is re-used and/or persisted.
          * Returns {@code null} when the value cannot be gotten.
          */
-        @Nullable
-        static String getResPackage(@NonNull Object icon) {
+        static @Nullable String getResPackage(@NonNull Object icon) {
             if (Build.VERSION.SDK_INT >= 28) {
                 return Api28Impl.getResPackage(icon);
             } else {
@@ -1229,8 +1203,7 @@
          * up to the caller to ensure safety if this uri is re-used and/or persisted.
          * Returns {@code null} if the uri cannot be gotten.
          */
-        @Nullable
-        static Uri getUri(@NonNull Object icon) {
+        static @Nullable Uri getUri(@NonNull Object icon) {
             if (Build.VERSION.SDK_INT >= 28) {
                 return Api28Impl.getUri(icon);
             } else {
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable.java b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable.java
index 4c9f959..148a7aa 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable.java
@@ -30,8 +30,8 @@
 import android.util.DisplayMetrics;
 import android.view.Gravity;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * A Drawable that wraps a bitmap and can be drawn with rounded corners. You can create a
@@ -67,16 +67,14 @@
     /**
      * Returns the paint used to render this drawable.
      */
-    @NonNull
-    public final Paint getPaint() {
+    public final @NonNull Paint getPaint() {
         return mPaint;
     }
 
     /**
      * Returns the bitmap used by this drawable to render. May be null.
      */
-    @Nullable
-    public final Bitmap getBitmap() {
+    public final @Nullable Bitmap getBitmap() {
         return mBitmap;
     }
 
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable21.java b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable21.java
index 4a32004..41f179a 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable21.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawable21.java
@@ -23,9 +23,10 @@
 import android.view.Gravity;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 @RequiresApi(21)
 class RoundedBitmapDrawable21 extends RoundedBitmapDrawable {
     protected RoundedBitmapDrawable21(Resources res, Bitmap bitmap) {
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawableFactory.java b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawableFactory.java
index c9c3cd3..4187efd 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawableFactory.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/RoundedBitmapDrawableFactory.java
@@ -24,11 +24,12 @@
 import android.util.Log;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.graphics.BitmapCompat;
 import androidx.core.view.GravityCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.InputStream;
 
 /**
@@ -68,8 +69,8 @@
      * Returns a new drawable by creating it from a bitmap, setting initial target density based on
      * the display metrics of the resources.
      */
-    @NonNull
-    public static RoundedBitmapDrawable create(@NonNull Resources res, @Nullable Bitmap bitmap) {
+    public static @NonNull RoundedBitmapDrawable create(@NonNull Resources res,
+            @Nullable Bitmap bitmap) {
         if (Build.VERSION.SDK_INT >= 21) {
             return new RoundedBitmapDrawable21(res, bitmap);
         }
@@ -79,8 +80,8 @@
     /**
      * Returns a new drawable, creating it by opening a given file path and decoding the bitmap.
      */
-    @NonNull
-    public static RoundedBitmapDrawable create(@NonNull Resources res, @NonNull String filepath) {
+    public static @NonNull RoundedBitmapDrawable create(@NonNull Resources res,
+            @NonNull String filepath) {
         final RoundedBitmapDrawable drawable = create(res, BitmapFactory.decodeFile(filepath));
         if (drawable.getBitmap() == null) {
             Log.w(TAG, "RoundedBitmapDrawable cannot decode " + filepath);
@@ -92,8 +93,8 @@
     /**
      * Returns a new drawable, creating it by decoding a bitmap from the given input stream.
      */
-    @NonNull
-    public static RoundedBitmapDrawable create(@NonNull Resources res, @NonNull InputStream is) {
+    public static @NonNull RoundedBitmapDrawable create(@NonNull Resources res,
+            @NonNull InputStream is) {
         final RoundedBitmapDrawable drawable = create(res, BitmapFactory.decodeStream(is));
         if (drawable.getBitmap() == null) {
             Log.w(TAG, "RoundedBitmapDrawable cannot decode " + is);
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi14.java b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi14.java
index e84153e..675df09b 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi14.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi14.java
@@ -25,10 +25,11 @@
 import android.graphics.Region;
 import android.graphics.drawable.Drawable;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Drawable which delegates all calls to its wrapped {@link Drawable}.
  * <p/>
@@ -134,21 +135,19 @@
     }
 
     @Override
-    public boolean setState(@NonNull int[] stateSet) {
+    public boolean setState(int @NonNull [] stateSet) {
         boolean handled = mDrawable.setState(stateSet);
         handled = updateTint(stateSet) || handled;
         return handled;
     }
 
-    @NonNull
     @Override
-    public int[] getState() {
+    public int @NonNull [] getState() {
         return mDrawable.getState();
     }
 
-    @NonNull
     @Override
-    public Drawable getCurrent() {
+    public @NonNull Drawable getCurrent() {
         return mDrawable.getCurrent();
     }
 
@@ -215,8 +214,7 @@
     }
 
     @Override
-    @Nullable
-    public ConstantState getConstantState() {
+    public @Nullable ConstantState getConstantState() {
         if (mState != null && mState.canConstantState()) {
             mState.mChangingConfigurations = getChangingConfigurations();
             return mState;
@@ -224,9 +222,8 @@
         return null;
     }
 
-    @NonNull
     @Override
-    public Drawable mutate() {
+    public @NonNull Drawable mutate() {
         if (!mMutated && super.mutate() == this) {
             mState = mutateConstantState();
             if (mDrawable != null) {
@@ -248,8 +245,7 @@
      *
      * @return the new state
      */
-    @NonNull
-    private WrappedDrawableState mutateConstantState() {
+    private @NonNull WrappedDrawableState mutateConstantState() {
         return new WrappedDrawableState(mState);
     }
 
@@ -294,7 +290,7 @@
     }
 
     @Override
-    public void setTintMode(@NonNull PorterDuff.Mode tintMode) {
+    public void setTintMode(PorterDuff.@NonNull Mode tintMode) {
         mState.mTintMode = tintMode;
         updateTint(getState());
     }
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi21.java b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi21.java
index e75bf07..bd6f040 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi21.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableApi21.java
@@ -29,9 +29,10 @@
 import android.os.Build;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Method;
 
 @RequiresApi(21)
@@ -64,9 +65,8 @@
         mDrawable.getOutline(outline);
     }
 
-    @NonNull
     @Override
-    public Rect getDirtyBounds() {
+    public @NonNull Rect getDirtyBounds() {
         return mDrawable.getDirtyBounds();
     }
 
@@ -89,7 +89,7 @@
     }
 
     @Override
-    public void setTintMode(@NonNull PorterDuff.Mode tintMode) {
+    public void setTintMode(PorterDuff.@NonNull Mode tintMode) {
         if (isCompatTintEnabled()) {
             super.setTintMode(tintMode);
         } else {
@@ -98,7 +98,7 @@
     }
 
     @Override
-    public boolean setState(@NonNull int[] stateSet) {
+    public boolean setState(int @NonNull [] stateSet) {
         if (super.setState(stateSet)) {
             // Manually invalidate because the framework doesn't currently force an invalidation
             // on a state change
diff --git a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableState.java b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableState.java
index d9a5149..be35b25 100644
--- a/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableState.java
+++ b/core/core/src/main/java/androidx/core/graphics/drawable/WrappedDrawableState.java
@@ -22,8 +22,8 @@
 import android.graphics.drawable.Drawable;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 final class WrappedDrawableState extends Drawable.ConstantState {
     int mChangingConfigurations;
@@ -41,15 +41,13 @@
         }
     }
 
-    @NonNull
     @Override
-    public Drawable newDrawable() {
+    public @NonNull Drawable newDrawable() {
         return newDrawable(null);
     }
 
-    @NonNull
     @Override
-    public Drawable newDrawable(@Nullable Resources res) {
+    public @NonNull Drawable newDrawable(@Nullable Resources res) {
         if (Build.VERSION.SDK_INT >= 21) {
             return new WrappedDrawableApi21(this, res);
         }
diff --git a/core/core/src/main/java/androidx/core/hardware/display/DisplayManagerCompat.java b/core/core/src/main/java/androidx/core/hardware/display/DisplayManagerCompat.java
index 396cadf..49a4630 100644
--- a/core/core/src/main/java/androidx/core/hardware/display/DisplayManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/hardware/display/DisplayManagerCompat.java
@@ -20,8 +20,8 @@
 import android.hardware.display.DisplayManager;
 import android.view.Display;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper for accessing features in {@link android.hardware.display.DisplayManager}.
@@ -52,8 +52,7 @@
     /**
      * Gets an instance of the display manager given the context.
      */
-    @NonNull
-    public static DisplayManagerCompat getInstance(@NonNull Context context) {
+    public static @NonNull DisplayManagerCompat getInstance(@NonNull Context context) {
         return new DisplayManagerCompat(context);
     }
 
@@ -66,9 +65,8 @@
      * @param displayId The logical display id.
      * @return The display object, or null if there is no valid display with the given id.
      */
-    @Nullable
     @SuppressWarnings("deprecation")
-    public Display getDisplay(int displayId) {
+    public @Nullable Display getDisplay(int displayId) {
         DisplayManager displayManager =
                 (DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE);
         return displayManager.getDisplay(displayId);
@@ -80,8 +78,7 @@
      * @return An array containing all displays.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public Display[] getDisplays() {
+    public Display @NonNull [] getDisplays() {
         return ((DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE)).getDisplays();
     }
 
@@ -101,9 +98,8 @@
      *
      * @see #DISPLAY_CATEGORY_PRESENTATION
      */
-    @NonNull
     @SuppressWarnings("deprecation")
-    public Display[] getDisplays(@Nullable String category) {
+    public Display @NonNull [] getDisplays(@Nullable String category) {
         return ((DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE)).getDisplays();
     }
 }
diff --git a/core/core/src/main/java/androidx/core/hardware/fingerprint/FingerprintManagerCompat.java b/core/core/src/main/java/androidx/core/hardware/fingerprint/FingerprintManagerCompat.java
index 0244fbc..a18a952 100644
--- a/core/core/src/main/java/androidx/core/hardware/fingerprint/FingerprintManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/hardware/fingerprint/FingerprintManagerCompat.java
@@ -24,12 +24,13 @@
 import android.os.CancellationSignal;
 import android.os.Handler;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.security.Signature;
 
 import javax.crypto.Cipher;
@@ -51,8 +52,7 @@
     private final Context mContext;
 
     /** Get a {@link FingerprintManagerCompat} instance for a provided context. */
-    @NonNull
-    public static FingerprintManagerCompat from(@NonNull Context context) {
+    public static @NonNull FingerprintManagerCompat from(@NonNull Context context) {
         return new FingerprintManagerCompat(context);
     }
 
@@ -110,7 +110,7 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
     @RequiresPermission(Manifest.permission.USE_FINGERPRINT)
     public void authenticate(@Nullable CryptoObject crypto, int flags,
-            @Nullable androidx.core.os.CancellationSignal cancel,
+            androidx.core.os.@Nullable CancellationSignal cancel,
             @NonNull AuthenticationCallback callback,
             @Nullable Handler handler) {
         authenticate(crypto, flags,
@@ -145,9 +145,9 @@
         }
     }
 
-    @Nullable
     @RequiresApi(23)
-    private static FingerprintManager getFingerprintManagerOrNull(@NonNull Context context) {
+    private static @Nullable FingerprintManager getFingerprintManagerOrNull(
+            @NonNull Context context) {
         return Api23Impl.getFingerprintManagerOrNull(context);
     }
 
@@ -221,22 +221,19 @@
          * Get {@link Signature} object.
          * @return {@link Signature} object or null if this doesn't contain one.
          */
-        @Nullable
-        public Signature getSignature() { return mSignature; }
+        public @Nullable Signature getSignature() { return mSignature; }
 
         /**
          * Get {@link Cipher} object.
          * @return {@link Cipher} object or null if this doesn't contain one.
          */
-        @Nullable
-        public Cipher getCipher() { return mCipher; }
+        public @Nullable Cipher getCipher() { return mCipher; }
 
         /**
          * Get {@link Mac} object.
          * @return {@link Mac} object or null if this doesn't contain one.
          */
-        @Nullable
-        public Mac getMac() { return mMac; }
+        public @Nullable Mac getMac() { return mMac; }
     }
 
     /**
@@ -255,8 +252,7 @@
          * @return crypto object provided to {@link FingerprintManagerCompat#authenticate(
          *         CryptoObject, int, CancellationSignal, AuthenticationCallback, Handler)}.
          */
-        @NonNull
-        public CryptoObject getCryptoObject() { return mCryptoObject; }
+        public @NonNull CryptoObject getCryptoObject() { return mCryptoObject; }
     }
 
     /**
diff --git a/core/core/src/main/java/androidx/core/internal/package-info.java b/core/core/src/main/java/androidx/core/internal/package-info.java
index 65a385c..dfc15fe 100644
--- a/core/core/src/main/java/androidx/core/internal/package-info.java
+++ b/core/core/src/main/java/androidx/core/internal/package-info.java
@@ -5,4 +5,4 @@
 
 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
 
-import androidx.annotation.RestrictTo;
\ No newline at end of file
+import androidx.annotation.RestrictTo;
diff --git a/core/core/src/main/java/androidx/core/internal/view/SupportMenuItem.java b/core/core/src/main/java/androidx/core/internal/view/SupportMenuItem.java
index 54c0454..e04f94f 100644
--- a/core/core/src/main/java/androidx/core/internal/view/SupportMenuItem.java
+++ b/core/core/src/main/java/androidx/core/internal/view/SupportMenuItem.java
@@ -24,11 +24,12 @@
 import android.view.MenuItem;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.view.ActionProvider;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Interface for direct access to a previously created menu item.
  *
@@ -104,9 +105,8 @@
      * @see android.app.ActionBar
      * @see #setActionView(View)
      */
-    @NonNull
     @Override
-    MenuItem setShowAsActionFlags(int actionEnum);
+    @NonNull MenuItem setShowAsActionFlags(int actionEnum);
 
     /**
      * Set an action view for this menu item. An action view will be displayed in place
@@ -120,9 +120,8 @@
      * @return This Item so additional setters can be called.
      * @see #setShowAsAction(int)
      */
-    @NonNull
     @Override
-    MenuItem setActionView(@Nullable View view);
+    @NonNull MenuItem setActionView(@Nullable View view);
 
     /**
      * Set an action view for this menu item. An action view will be displayed in place
@@ -136,9 +135,8 @@
      * @return This Item so additional setters can be called.
      * @see #setShowAsAction(int)
      */
-    @NonNull
     @Override
-    MenuItem setActionView(int resId);
+    @NonNull MenuItem setActionView(int resId);
 
     /**
      * Returns the currently set action view for this menu item.
@@ -147,9 +145,8 @@
      * @see #setActionView(View)
      * @see #setShowAsAction(int)
      */
-    @Nullable
     @Override
-    View getActionView();
+    @Nullable View getActionView();
 
     /**
      * Sets the {@link ActionProvider} responsible for creating an action view if
@@ -164,8 +161,7 @@
      * @return This Item so additional setters can be called.
      * @see ActionProvider
      */
-    @NonNull
-    SupportMenuItem setSupportActionProvider(@Nullable ActionProvider actionProvider);
+    @NonNull SupportMenuItem setSupportActionProvider(@Nullable ActionProvider actionProvider);
 
     /**
      * Gets the {@link ActionProvider}.
@@ -174,8 +170,7 @@
      * @see ActionProvider
      * @see #setSupportActionProvider(ActionProvider)
      */
-    @Nullable
-    ActionProvider getSupportActionProvider();
+    @Nullable ActionProvider getSupportActionProvider();
 
     /**
      * Expand the action view associated with this menu item. The menu item must have an action view
@@ -223,18 +218,16 @@
      * @param contentDescription The new content description.
      * @return This menu item instance for call chaining.
      */
-    @NonNull
     @Override
-    SupportMenuItem setContentDescription(@Nullable CharSequence contentDescription);
+    @NonNull SupportMenuItem setContentDescription(@Nullable CharSequence contentDescription);
 
     /**
      * Retrieve the content description associated with this menu item.
      *
      * @return The content description.
      */
-    @Nullable
     @Override
-    CharSequence getContentDescription();
+    @Nullable CharSequence getContentDescription();
 
     /**
      * Change the tooltip text associated with this menu item.
@@ -242,18 +235,16 @@
      * @param tooltipText The new tooltip text.
      * @return This menu item instance for call chaining.
      */
-    @NonNull
     @Override
-    SupportMenuItem setTooltipText(@Nullable CharSequence tooltipText);
+    @NonNull SupportMenuItem setTooltipText(@Nullable CharSequence tooltipText);
 
     /**
      * Retrieve the tooltip text associated with this menu item.
      *
      * @return The tooltip text.
      */
-    @Nullable
     @Override
-    CharSequence getTooltipText();
+    @Nullable CharSequence getTooltipText();
 
     /**
      * Change both the numeric and alphabetic shortcut associated with this
@@ -278,9 +269,8 @@
      *        {@link KeyEvent#META_SYM_ON}, {@link KeyEvent#META_FUNCTION_ON}.
      * @return This Item so additional setters can be called.
      */
-    @NonNull
     @Override
-    MenuItem setShortcut(char numericChar, char alphaChar, int numericModifiers,
+    @NonNull MenuItem setShortcut(char numericChar, char alphaChar, int numericModifiers,
             int alphaModifiers);
 
     /**
@@ -296,9 +286,8 @@
      *        {@link KeyEvent#META_SYM_ON}, {@link KeyEvent#META_FUNCTION_ON}.
      * @return This Item so additional setters can be called.
      */
-    @NonNull
     @Override
-    MenuItem setNumericShortcut(char numericChar, int numericModifiers);
+    @NonNull MenuItem setNumericShortcut(char numericChar, int numericModifiers);
 
     /**
      * Return the modifiers for this menu item's numeric (12-key) shortcut.
@@ -331,9 +320,8 @@
      *        {@link KeyEvent#META_SYM_ON}, {@link KeyEvent#META_FUNCTION_ON}.
      * @return This Item so additional setters can be called.
      */
-    @NonNull
     @Override
-    MenuItem setAlphabeticShortcut(char alphaChar, int alphaModifiers);
+    @NonNull MenuItem setAlphabeticShortcut(char alphaChar, int alphaModifiers);
 
     /**
      * Return the modifier for this menu item's alphabetic shortcut.
@@ -360,17 +348,15 @@
      *
      * @see #getIconTintList()
      */
-    @NonNull
     @Override
-    MenuItem setIconTintList(@Nullable ColorStateList tint);
+    @NonNull MenuItem setIconTintList(@Nullable ColorStateList tint);
 
     /**
      * @return the tint applied to this item's icon
      * @see #setIconTintList(ColorStateList)
      */
-    @Nullable
     @Override
-    ColorStateList getIconTintList();
+    @Nullable ColorStateList getIconTintList();
 
     /**
      * Specifies the blending mode used to apply the tint specified by
@@ -381,9 +367,8 @@
      *                 {@code null} to clear tint
      * @see #setIconTintList(ColorStateList)
      */
-    @NonNull
     @Override
-    MenuItem setIconTintMode(@Nullable PorterDuff.Mode tintMode);
+    @NonNull MenuItem setIconTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Returns the blending mode used to apply the tint to this item's icon, if specified.
@@ -391,9 +376,8 @@
      * @return the blending mode used to apply the tint to this item's icon
      * @see #setIconTintMode(PorterDuff.Mode)
      */
-    @Nullable
     @Override
-    PorterDuff.Mode getIconTintMode();
+    PorterDuff.@Nullable Mode getIconTintMode();
 
     /**
      * Returns true if {@link #setShowAsAction(int)} was set to {@link #SHOW_AS_ACTION_ALWAYS}.
diff --git a/core/core/src/main/java/androidx/core/location/GnssStatusCompat.java b/core/core/src/main/java/androidx/core/location/GnssStatusCompat.java
index a547b44..85dfca6 100644
--- a/core/core/src/main/java/androidx/core/location/GnssStatusCompat.java
+++ b/core/core/src/main/java/androidx/core/location/GnssStatusCompat.java
@@ -28,10 +28,11 @@
 import androidx.annotation.FloatRange;
 import androidx.annotation.IntDef;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
@@ -111,8 +112,7 @@
      * Wraps the given {@link GnssStatus} as GnssStatusCompat.
      */
     @RequiresApi(VERSION_CODES.N)
-    @NonNull
-    public static GnssStatusCompat wrap(@NonNull GnssStatus gnssStatus) {
+    public static @NonNull GnssStatusCompat wrap(@NonNull GnssStatus gnssStatus) {
         return new GnssStatusWrapper(gnssStatus);
     }
 
@@ -120,8 +120,7 @@
      * Wraps the given {@link GpsStatus} as GnssStatusCompat.
      */
     @SuppressLint("ReferencesDeprecated")
-    @NonNull
-    public static GnssStatusCompat wrap(@NonNull GpsStatus gpsStatus) {
+    public static @NonNull GnssStatusCompat wrap(@NonNull GpsStatus gpsStatus) {
         return new GpsStatusWrapper(gpsStatus);
     }
 
diff --git a/core/core/src/main/java/androidx/core/location/LocationCompat.java b/core/core/src/main/java/androidx/core/location/LocationCompat.java
index 49c4c79..8c4d3c2 100644
--- a/core/core/src/main/java/androidx/core/location/LocationCompat.java
+++ b/core/core/src/main/java/androidx/core/location/LocationCompat.java
@@ -24,10 +24,11 @@
 import android.os.Bundle;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -76,16 +77,11 @@
     public static final String EXTRA_MSL_ALTITUDE_ACCURACY =
             "androidx.core.location.extra.MSL_ALTITUDE_ACCURACY";
 
-    @Nullable
-    private static Method sSetIsFromMockProviderMethod;
-    @Nullable
-    private static Field sFieldsMaskField;
-    @Nullable
-    private static Integer sHasSpeedAccuracyMask;
-    @Nullable
-    private static Integer sHasBearingAccuracyMask;
-    @Nullable
-    private static Integer sHasVerticalAccuracyMask;
+    private static @Nullable Method sSetIsFromMockProviderMethod;
+    private static @Nullable Field sFieldsMaskField;
+    private static @Nullable Integer sHasSpeedAccuracyMask;
+    private static @Nullable Integer sHasBearingAccuracyMask;
+    private static @Nullable Integer sHasVerticalAccuracyMask;
 
     private LocationCompat() {
     }
diff --git a/core/core/src/main/java/androidx/core/location/LocationListenerCompat.java b/core/core/src/main/java/androidx/core/location/LocationListenerCompat.java
index 070815b..3c5ef5e 100644
--- a/core/core/src/main/java/androidx/core/location/LocationListenerCompat.java
+++ b/core/core/src/main/java/androidx/core/location/LocationListenerCompat.java
@@ -20,8 +20,8 @@
 import android.location.LocationListener;
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.List;
 
diff --git a/core/core/src/main/java/androidx/core/location/LocationManagerCompat.java b/core/core/src/main/java/androidx/core/location/LocationManagerCompat.java
index f735336..44e85f9 100644
--- a/core/core/src/main/java/androidx/core/location/LocationManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/location/LocationManagerCompat.java
@@ -47,8 +47,6 @@
 import android.text.TextUtils;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 import androidx.collection.SimpleArrayMap;
@@ -57,6 +55,9 @@
 import androidx.core.util.ObjectsCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -186,8 +187,8 @@
     @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION})
     public static void getCurrentLocation(@NonNull LocationManager locationManager,
             @NonNull String provider,
-            @Nullable androidx.core.os.CancellationSignal cancellationSignal,
-            @NonNull Executor executor, @NonNull final Consumer<Location> consumer) {
+            androidx.core.os.@Nullable CancellationSignal cancellationSignal,
+            @NonNull Executor executor, final @NonNull Consumer<Location> consumer) {
         getCurrentLocation(locationManager, provider, cancellationSignal != null
                         ? (CancellationSignal) cancellationSignal.getCancellationSignalObject() :
                         null,
@@ -215,7 +216,7 @@
     @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION})
     public static void getCurrentLocation(@NonNull LocationManager locationManager,
             @NonNull String provider, @Nullable CancellationSignal cancellationSignal,
-            @NonNull Executor executor, @NonNull final Consumer<Location> consumer) {
+            @NonNull Executor executor, final @NonNull Consumer<Location> consumer) {
         if (VERSION.SDK_INT >= 30) {
             Api30Impl.getCurrentLocation(locationManager, provider, cancellationSignal, executor,
                     consumer);
@@ -380,8 +381,8 @@
      *
      * <p>No device-specific serial number or ID is returned from this API.
      */
-    @Nullable
-    public static String getGnssHardwareModelName(@NonNull LocationManager locationManager) {
+    public static @Nullable String getGnssHardwareModelName(
+            @NonNull LocationManager locationManager) {
         if (VERSION.SDK_INT >= 28) {
             return Api28Impl.getGnssHardwareModelName(locationManager);
         } else {
@@ -423,7 +424,7 @@
     @RequiresApi(VERSION_CODES.N)
     @RequiresPermission(ACCESS_FINE_LOCATION)
     public static boolean registerGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-            @NonNull GnssMeasurementsEvent.Callback callback, @NonNull Handler handler) {
+            GnssMeasurementsEvent.@NonNull Callback callback, @NonNull Handler handler) {
         if (VERSION.SDK_INT > VERSION_CODES.R) {
             return Api24Impl.registerGnssMeasurementsCallback(locationManager, callback, handler);
         } else if (VERSION.SDK_INT == VERSION_CODES.R) {
@@ -457,7 +458,7 @@
     @RequiresApi(VERSION_CODES.N)
     @RequiresPermission(ACCESS_FINE_LOCATION)
     public static boolean registerGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-            @NonNull Executor executor, @NonNull GnssMeasurementsEvent.Callback callback) {
+            @NonNull Executor executor, GnssMeasurementsEvent.@NonNull Callback callback) {
         if (VERSION.SDK_INT > VERSION_CODES.R) {
             return Api31Impl.registerGnssMeasurementsCallback(locationManager, executor, callback);
         } else if (VERSION.SDK_INT == VERSION_CODES.R) {
@@ -485,7 +486,7 @@
      */
     @RequiresApi(VERSION_CODES.N)
     public static void unregisterGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-            @NonNull GnssMeasurementsEvent.Callback callback) {
+            GnssMeasurementsEvent.@NonNull Callback callback) {
         if (VERSION.SDK_INT >= VERSION_CODES.R) {
             Api24Impl.unregisterGnssMeasurementsCallback(locationManager, callback);
         } else {
@@ -507,7 +508,7 @@
     @RequiresApi(VERSION_CODES.R)
     private static boolean registerGnssMeasurementsCallbackOnR(
             @NonNull LocationManager locationManager, @NonNull Executor executor,
-            @NonNull GnssMeasurementsEvent.Callback callback) {
+            GnssMeasurementsEvent.@NonNull Callback callback) {
         if (VERSION.SDK_INT == VERSION_CODES.R) {
             try {
                 if (sGnssRequestBuilderClass == null) {
@@ -551,7 +552,7 @@
      */
     @RequiresPermission(ACCESS_FINE_LOCATION)
     public static boolean registerGnssStatusCallback(@NonNull LocationManager locationManager,
-            @NonNull GnssStatusCompat.Callback callback, @NonNull Handler handler) {
+            GnssStatusCompat.@NonNull Callback callback, @NonNull Handler handler) {
         if (VERSION.SDK_INT >= VERSION_CODES.R) {
             return registerGnssStatusCallback(locationManager, ExecutorCompat.create(handler),
                 callback);
@@ -581,7 +582,7 @@
      */
     @RequiresPermission(ACCESS_FINE_LOCATION)
     public static boolean registerGnssStatusCallback(@NonNull LocationManager locationManager,
-            @NonNull Executor executor, @NonNull GnssStatusCompat.Callback callback) {
+            @NonNull Executor executor, GnssStatusCompat.@NonNull Callback callback) {
         if (VERSION.SDK_INT >= VERSION_CODES.R) {
             return registerGnssStatusCallback(locationManager, null, executor, callback);
         } else {
@@ -670,7 +671,7 @@
      * and {@link LocationManager#unregisterGnssStatusCallback(GnssStatus.Callback)}.
      */
     public static void unregisterGnssStatusCallback(@NonNull LocationManager locationManager,
-            @NonNull GnssStatusCompat.Callback callback) {
+            GnssStatusCompat.@NonNull Callback callback) {
         if (VERSION.SDK_INT >= 24) {
             synchronized (GnssListenersHolder.sGnssStatusListeners) {
                 Object transport = GnssListenersHolder.sGnssStatusListeners.remove(callback);
@@ -721,7 +722,7 @@
 
     private static class LocationListenerTransport implements LocationListener {
 
-        @Nullable volatile LocationListenerKey mKey;
+        volatile @Nullable LocationListenerKey mKey;
         final Executor mExecutor;
 
         LocationListenerTransport(@Nullable LocationListenerKey key, Executor executor) {
@@ -832,9 +833,9 @@
     private static class GnssMeasurementsTransport extends GnssMeasurementsEvent.Callback {
 
         final GnssMeasurementsEvent.Callback mCallback;
-        @Nullable volatile Executor mExecutor;
+        volatile @Nullable Executor mExecutor;
 
-        GnssMeasurementsTransport(@NonNull GnssMeasurementsEvent.Callback callback,
+        GnssMeasurementsTransport(GnssMeasurementsEvent.@NonNull Callback callback,
                 @NonNull Executor executor) {
             mCallback = callback;
             mExecutor = executor;
@@ -911,7 +912,7 @@
 
         final GnssStatusCompat.Callback mCallback;
 
-        @Nullable volatile Executor mExecutor;
+        volatile @Nullable Executor mExecutor;
 
         PreRGnssStatusTransport(GnssStatusCompat.Callback callback) {
             Preconditions.checkArgument(callback != null, "invalid null callback");
@@ -994,7 +995,7 @@
         private final LocationManager mLocationManager;
         final GnssStatusCompat.Callback mCallback;
 
-        @Nullable volatile Executor mExecutor;
+        volatile @Nullable Executor mExecutor;
 
         GpsStatusTransport(LocationManager locationManager,
                 GnssStatusCompat.Callback callback) {
@@ -1078,8 +1079,7 @@
         @GuardedBy("this")
         private boolean mTriggered;
 
-        @Nullable
-        Runnable mTimeoutRunnable;
+        @Nullable Runnable mTimeoutRunnable;
 
         CancellableLocationListener(LocationManager locationManager,
                 Executor executor, Consumer<Location> consumer) {
@@ -1134,7 +1134,7 @@
 
         @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION})
         @Override
-        public void onLocationChanged(@Nullable final Location location) {
+        public void onLocationChanged(final @Nullable Location location) {
             synchronized (this) {
                 if (mTriggered) {
                     return;
@@ -1207,7 +1207,7 @@
 
         @RequiresPermission(ACCESS_FINE_LOCATION)
         static boolean registerGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-                @NonNull Executor executor, @NonNull GnssMeasurementsEvent.Callback callback) {
+                @NonNull Executor executor, GnssMeasurementsEvent.@NonNull Callback callback) {
             return locationManager.registerGnssMeasurementsCallback(executor, callback);
         }
     }
@@ -1398,18 +1398,18 @@
 
         @RequiresPermission(ACCESS_FINE_LOCATION)
         static boolean registerGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-                @NonNull GnssMeasurementsEvent.Callback callback) {
+                GnssMeasurementsEvent.@NonNull Callback callback) {
             return locationManager.registerGnssMeasurementsCallback(callback);
         }
 
         @RequiresPermission(ACCESS_FINE_LOCATION)
         static boolean registerGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-                @NonNull GnssMeasurementsEvent.Callback callback, @NonNull Handler handler) {
+                GnssMeasurementsEvent.@NonNull Callback callback, @NonNull Handler handler) {
             return locationManager.registerGnssMeasurementsCallback(callback, handler);
         }
 
         static void unregisterGnssMeasurementsCallback(@NonNull LocationManager locationManager,
-                @NonNull GnssMeasurementsEvent.Callback callback) {
+                GnssMeasurementsEvent.@NonNull Callback callback) {
             locationManager.unregisterGnssMeasurementsCallback(callback);
         }
 
diff --git a/core/core/src/main/java/androidx/core/location/LocationRequestCompat.java b/core/core/src/main/java/androidx/core/location/LocationRequestCompat.java
index 2e0872b..f7f00c8 100644
--- a/core/core/src/main/java/androidx/core/location/LocationRequestCompat.java
+++ b/core/core/src/main/java/androidx/core/location/LocationRequestCompat.java
@@ -27,13 +27,14 @@
 import androidx.annotation.FloatRange;
 import androidx.annotation.IntDef;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.Preconditions;
 import androidx.core.util.TimeUtils;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.reflect.InvocationTargetException;
@@ -211,8 +212,7 @@
      * @see LocationRequest
      */
     @RequiresApi(31)
-    @NonNull
-    public LocationRequest toLocationRequest() {
+    public @NonNull LocationRequest toLocationRequest() {
         return Api31Impl.toLocationRequest(this);
     }
 
@@ -227,8 +227,7 @@
      * @see LocationRequest
      */
     @SuppressLint("NewApi")
-    @Nullable
-    public LocationRequest toLocationRequest(@NonNull String provider) {
+    public @Nullable LocationRequest toLocationRequest(@NonNull String provider) {
         if (VERSION.SDK_INT >= 31) {
             return toLocationRequest();
         } else {
@@ -263,8 +262,7 @@
     }
 
     @Override
-    @NonNull
-    public String toString() {
+    public @NonNull String toString() {
         StringBuilder s = new StringBuilder();
         s.append("Request[");
         if (mIntervalMillis != PASSIVE_INTERVAL) {
diff --git a/core/core/src/main/java/androidx/core/net/ConnectivityManagerCompat.java b/core/core/src/main/java/androidx/core/net/ConnectivityManagerCompat.java
index b1eba5e..c23c303 100644
--- a/core/core/src/main/java/androidx/core/net/ConnectivityManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/net/ConnectivityManagerCompat.java
@@ -26,12 +26,13 @@
 import android.os.Build;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
@@ -104,9 +105,8 @@
      */
     @SuppressWarnings("deprecation")
     @SuppressLint("ReferencesDeprecated")
-    @Nullable
     @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
-    public static NetworkInfo getNetworkInfoFromBroadcast(@NonNull ConnectivityManager cm,
+    public static @Nullable NetworkInfo getNetworkInfoFromBroadcast(@NonNull ConnectivityManager cm,
             @NonNull Intent intent) {
         final NetworkInfo info = intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO);
         if (info != null) {
diff --git a/core/core/src/main/java/androidx/core/net/MailTo.java b/core/core/src/main/java/androidx/core/net/MailTo.java
index 797fbdd..8bd9f3f 100644
--- a/core/core/src/main/java/androidx/core/net/MailTo.java
+++ b/core/core/src/main/java/androidx/core/net/MailTo.java
@@ -18,10 +18,11 @@
 
 import android.net.Uri;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
@@ -102,8 +103,7 @@
      * @return MailTo object
      * @exception ParseException if the scheme is not a mailto URI
      */
-    @NonNull
-    public static MailTo parse(@NonNull String uri) throws ParseException {
+    public static @NonNull MailTo parse(@NonNull String uri) throws ParseException {
         Preconditions.checkNotNull(uri);
 
         if (!isMailTo(uri)) {
@@ -173,8 +173,7 @@
      * @return MailTo object
      * @exception ParseException if the scheme is not a mailto URI
      */
-    @NonNull
-    public static MailTo parse(@NonNull Uri uri) throws ParseException {
+    public static @NonNull MailTo parse(@NonNull Uri uri) throws ParseException {
         return parse(uri.toString());
     }
 
@@ -184,8 +183,7 @@
      * If no To line was specified, then null is return
      * @return comma delimited email addresses or null
      */
-    @Nullable
-    public String getTo() {
+    public @Nullable String getTo() {
         return mHeaders.get(TO);
     }
 
@@ -195,8 +193,7 @@
      * If no CC line was specified, then null is return
      * @return comma delimited email addresses or null
      */
-    @Nullable
-    public String getCc() {
+    public @Nullable String getCc() {
         return mHeaders.get(CC);
     }
 
@@ -206,8 +203,7 @@
      * If no BCC line was specified, then null is return
      * @return comma delimited email addresses or null
      */
-    @Nullable
-    public String getBcc() {
+    public @Nullable String getBcc() {
         return mHeaders.get(BCC);
     }
 
@@ -216,8 +212,7 @@
      * If no subject line was specified, then null is return
      * @return subject or null
      */
-    @Nullable
-    public String getSubject() {
+    public @Nullable String getSubject() {
         return mHeaders.get(SUBJECT);
     }
 
@@ -226,8 +221,7 @@
      * If no body line was specified, then null is return
      * @return body or null
      */
-    @Nullable
-    public String getBody() {
+    public @Nullable String getBody() {
         return mHeaders.get(BODY);
     }
 
@@ -235,14 +229,12 @@
      * Retrieve all the parsed email headers from the mailto URI
      * @return map containing all parsed values
      */
-    @Nullable
-    public Map<String, String> getHeaders() {
+    public @Nullable Map<String, String> getHeaders() {
         return mHeaders;
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         StringBuilder sb = new StringBuilder(MAILTO_SCHEME);
         sb.append('?');
         for (Map.Entry<String, String> header : mHeaders.entrySet()) {
diff --git a/core/core/src/main/java/androidx/core/net/ParseException.java b/core/core/src/main/java/androidx/core/net/ParseException.java
index 796733e..10ec5a2 100644
--- a/core/core/src/main/java/androidx/core/net/ParseException.java
+++ b/core/core/src/main/java/androidx/core/net/ParseException.java
@@ -16,14 +16,13 @@
 
 package androidx.core.net;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Thrown when parsing a mailto URI has failed.
  */
 public class ParseException extends RuntimeException {
-    @NonNull
-    public final String response;
+    public final @NonNull String response;
 
     ParseException(@NonNull String response) {
         super(response);
diff --git a/core/core/src/main/java/androidx/core/net/TrafficStatsCompat.java b/core/core/src/main/java/androidx/core/net/TrafficStatsCompat.java
index ee3f6b7..35dfab0 100644
--- a/core/core/src/main/java/androidx/core/net/TrafficStatsCompat.java
+++ b/core/core/src/main/java/androidx/core/net/TrafficStatsCompat.java
@@ -20,9 +20,10 @@
 import android.os.Build;
 import android.os.ParcelFileDescriptor;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.net.DatagramSocket;
 import java.net.Socket;
 import java.net.SocketException;
diff --git a/core/core/src/main/java/androidx/core/net/UriCompat.java b/core/core/src/main/java/androidx/core/net/UriCompat.java
index 77834b7..825cfd2 100644
--- a/core/core/src/main/java/androidx/core/net/UriCompat.java
+++ b/core/core/src/main/java/androidx/core/net/UriCompat.java
@@ -18,7 +18,7 @@
 
 import android.net.Uri;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing function in {@link Uri} in a backwards compatible fashion.
diff --git a/core/core/src/main/java/androidx/core/os/BundleCompat.java b/core/core/src/main/java/androidx/core/os/BundleCompat.java
index 4e47124..ec80c10 100644
--- a/core/core/src/main/java/androidx/core/os/BundleCompat.java
+++ b/core/core/src/main/java/androidx/core/os/BundleCompat.java
@@ -23,10 +23,11 @@
 import android.os.Parcelable;
 import android.util.SparseArray;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.Serializable;
 import java.util.ArrayList;
 
@@ -62,9 +63,8 @@
      * @param clazz The type of the object expected
      * @return a Parcelable value, or {@code null}
      */
-    @Nullable
     @SuppressWarnings({"deprecation", "unchecked"})
-    public static <T> T getParcelable(@NonNull Bundle in, @Nullable String key,
+    public static <T> @Nullable T getParcelable(@NonNull Bundle in, @Nullable String key,
             @NonNull Class<T> clazz) {
         // Even though API was introduced in 33, we use 34 as 33 is bugged in some scenarios.
         if (Build.VERSION.SDK_INT >= 34) {
@@ -98,11 +98,10 @@
      * @param clazz The type of the items inside the array. This is only verified when unparceling.
      * @return a Parcelable[] value, or {@code null}
      */
-    @Nullable
     @SuppressWarnings({"deprecation"})
     @SuppressLint({"ArrayReturn", "NullableCollection"})
-    public static Parcelable[] getParcelableArray(@NonNull Bundle in, @Nullable String key,
-            @NonNull Class<? extends Parcelable> clazz) {
+    public static Parcelable @Nullable [] getParcelableArray(@NonNull Bundle in,
+            @Nullable String key, @NonNull Class<? extends Parcelable> clazz) {
         // Even though API was introduced in 33, we use 34 as 33 is bugged in some scenarios.
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.getParcelableArray(in, key, clazz);
@@ -135,11 +134,10 @@
      *     unparceling.
      * @return an ArrayList<T> value, or {@code null}
      */
-    @Nullable
     @SuppressWarnings({"deprecation", "unchecked"})
     @SuppressLint({"ConcreteCollection", "NullableCollection"})
-    public static  <T> ArrayList<T> getParcelableArrayList(@NonNull Bundle in, @Nullable String key,
-            @NonNull Class<? extends T> clazz) {
+    public static  <T> @Nullable ArrayList<T> getParcelableArrayList(@NonNull Bundle in,
+            @Nullable String key, @NonNull Class<? extends T> clazz) {
         // Even though API was introduced in 33, we use 34 as 33 is bugged in some scenarios.
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.getParcelableArrayList(in, key, clazz);
@@ -169,8 +167,7 @@
      * @return a SparseArray of T values, or null
      */
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <T> SparseArray<T> getSparseParcelableArray(@NonNull Bundle in,
+    public static <T> @Nullable SparseArray<T> getSparseParcelableArray(@NonNull Bundle in,
             @Nullable String key, @NonNull Class<? extends T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.getSparseParcelableArray(in, key, clazz);
@@ -191,8 +188,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "bundle.getBinder(key)")
     @Deprecated
-    @Nullable
-    public static IBinder getBinder(@NonNull Bundle bundle, @Nullable String key) {
+    public static @Nullable IBinder getBinder(@NonNull Bundle bundle, @Nullable String key) {
         return bundle.getBinder(key);
     }
 
@@ -233,8 +229,7 @@
      * @return a Serializable value, or {@code null}
      */
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <T extends Serializable> T getSerializable(@NonNull Bundle in,
+    public static <T extends Serializable> @Nullable T getSerializable(@NonNull Bundle in,
             @Nullable String key, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.getSerializable(in, key, clazz);
diff --git a/core/core/src/main/java/androidx/core/os/CancellationSignal.java b/core/core/src/main/java/androidx/core/os/CancellationSignal.java
index 85486f8..ac20827 100644
--- a/core/core/src/main/java/androidx/core/os/CancellationSignal.java
+++ b/core/core/src/main/java/androidx/core/os/CancellationSignal.java
@@ -18,7 +18,7 @@
 
 import android.os.Build;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Static library support version of the framework's {@link android.os.CancellationSignal}.
@@ -138,8 +138,7 @@
      * @return A framework cancellation signal object, or null on platform versions
      * prior to Jellybean.
      */
-    @Nullable
-    public Object getCancellationSignalObject() {
+    public @Nullable Object getCancellationSignalObject() {
         synchronized (this) {
             if (mCancellationSignalObj == null) {
                 mCancellationSignalObj = new android.os.CancellationSignal();
diff --git a/core/core/src/main/java/androidx/core/os/ConfigurationCompat.java b/core/core/src/main/java/androidx/core/os/ConfigurationCompat.java
index a1360cc..082060c 100644
--- a/core/core/src/main/java/androidx/core/os/ConfigurationCompat.java
+++ b/core/core/src/main/java/androidx/core/os/ConfigurationCompat.java
@@ -21,9 +21,10 @@
 import android.content.res.Configuration;
 import android.os.LocaleList;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Locale;
 
 /**
@@ -40,8 +41,7 @@
      * @return The locale list.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static LocaleListCompat getLocales(@NonNull Configuration configuration) {
+    public static @NonNull LocaleListCompat getLocales(@NonNull Configuration configuration) {
         if (SDK_INT >= 24) {
             return LocaleListCompat.wrap(Api24Impl.getLocales(configuration));
         } else {
diff --git a/core/core/src/main/java/androidx/core/os/EnvironmentCompat.java b/core/core/src/main/java/androidx/core/os/EnvironmentCompat.java
index e49fe27..6258903 100644
--- a/core/core/src/main/java/androidx/core/os/EnvironmentCompat.java
+++ b/core/core/src/main/java/androidx/core/os/EnvironmentCompat.java
@@ -19,9 +19,10 @@
 import android.os.Build;
 import android.os.Environment;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.File;
 
 /**
@@ -55,8 +56,7 @@
      *         {@link Environment#MEDIA_UNMOUNTABLE}.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static String getStorageState(@NonNull File path) {
+    public static @NonNull String getStorageState(@NonNull File path) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getExternalStorageState(path);
         } else {
diff --git a/core/core/src/main/java/androidx/core/os/ExecutorCompat.java b/core/core/src/main/java/androidx/core/os/ExecutorCompat.java
index bbe6047..354f922 100644
--- a/core/core/src/main/java/androidx/core/os/ExecutorCompat.java
+++ b/core/core/src/main/java/androidx/core/os/ExecutorCompat.java
@@ -18,9 +18,10 @@
 
 import android.os.Handler;
 
-import androidx.annotation.NonNull;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 import java.util.concurrent.RejectedExecutionException;
 
diff --git a/core/core/src/main/java/androidx/core/os/HandlerCompat.java b/core/core/src/main/java/androidx/core/os/HandlerCompat.java
index 1f5917d..74fda00 100644
--- a/core/core/src/main/java/androidx/core/os/HandlerCompat.java
+++ b/core/core/src/main/java/androidx/core/os/HandlerCompat.java
@@ -22,10 +22,11 @@
 import android.os.Message;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 
@@ -57,8 +58,7 @@
      * @see Handler#createAsync(Looper)
      */
     @SuppressWarnings("JavaReflectionMemberAccess")
-    @NonNull
-    public static Handler createAsync(@NonNull Looper looper) {
+    public static @NonNull Handler createAsync(@NonNull Looper looper) {
         Exception wrappedException;
 
         if (Build.VERSION.SDK_INT >= 28) {
@@ -116,8 +116,8 @@
      * @see Handler#createAsync(Looper, Handler.Callback)
      */
     @SuppressWarnings("JavaReflectionMemberAccess")
-    @NonNull
-    public static Handler createAsync(@NonNull Looper looper, @NonNull Handler.Callback callback) {
+    public static @NonNull Handler createAsync(@NonNull Looper looper,
+            Handler.@NonNull Callback callback) {
         Exception wrappedException;
 
         if (Build.VERSION.SDK_INT >= 28) {
diff --git a/core/core/src/main/java/androidx/core/os/LocaleListCompat.java b/core/core/src/main/java/androidx/core/os/LocaleListCompat.java
index b24537f..9e758786 100644
--- a/core/core/src/main/java/androidx/core/os/LocaleListCompat.java
+++ b/core/core/src/main/java/androidx/core/os/LocaleListCompat.java
@@ -20,12 +20,13 @@
 import android.os.LocaleList;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.Size;
 import androidx.core.text.ICUCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 
 /**
@@ -51,8 +52,7 @@
      * Creates a new instance of {@link LocaleListCompat} from the Locale list.
      */
     @RequiresApi(24)
-    @NonNull
-    public static LocaleListCompat wrap(@NonNull LocaleList localeList) {
+    public static @NonNull LocaleListCompat wrap(@NonNull LocaleList localeList) {
         return new LocaleListCompat(new LocaleListPlatformWrapper(localeList));
     }
 
@@ -61,16 +61,14 @@
      *
      * @return an android.os.LocaleList object if API &gt;= 24 , or {@code null} if not.
      */
-    @Nullable
-    public Object unwrap() {
+    public @Nullable Object unwrap() {
         return mImpl.getLocaleList();
     }
 
     /**
      * Creates a new instance of {@link LocaleListCompat} from the {@link Locale} array.
      */
-    @NonNull
-    public static LocaleListCompat create(@NonNull Locale... localeList) {
+    public static @NonNull LocaleListCompat create(Locale @NonNull ... localeList) {
         if (Build.VERSION.SDK_INT >= 24) {
             return wrap(Api24Impl.createLocaleList(localeList));
         }
@@ -83,8 +81,7 @@
      * @param index The position to retrieve.
      * @return The {@link Locale} in the given index
      */
-    @Nullable
-    public Locale get(int index) {
+    public @Nullable Locale get(int index) {
         return mImpl.get(index);
     }
 
@@ -122,8 +119,7 @@
     /**
      * Retrieves a String representation of the language tags in this list.
      */
-    @NonNull
-    public String toLanguageTags() {
+    public @NonNull String toLanguageTags() {
         return mImpl.toLanguageTags();
     }
 
@@ -134,16 +130,14 @@
      * @return The first {@link Locale} from this list that appears in the given array, or
      *         {@code null} if the {@link LocaleListCompat} is empty.
      */
-    @Nullable
-    public Locale getFirstMatch(@NonNull String[] supportedLocales) {
+    public @Nullable Locale getFirstMatch(String @NonNull [] supportedLocales) {
         return mImpl.getFirstMatch(supportedLocales);
     }
 
     /**
      * Retrieve an empty instance of {@link LocaleListCompat}.
      */
-    @NonNull
-    public static LocaleListCompat getEmptyLocaleList() {
+    public static @NonNull LocaleListCompat getEmptyLocaleList() {
         return sEmptyLocaleList;
     }
 
@@ -155,8 +149,7 @@
      * @param list The language tags to be included as a single {@link String} separated by commas.
      * @return A new instance with the {@link Locale} items identified by the given tags.
      */
-    @NonNull
-    public static LocaleListCompat forLanguageTags(@Nullable String list) {
+    public static @NonNull LocaleListCompat forLanguageTags(@Nullable String list) {
         if (list == null || list.isEmpty()) {
             return getEmptyLocaleList();
         } else {
@@ -202,8 +195,8 @@
      * Returns the default locale list, adjusted by moving the default locale to its first
      * position.
      */
-    @NonNull @Size(min = 1)
-    public static LocaleListCompat getAdjustedDefault() {
+    @Size(min = 1)
+    public static @NonNull LocaleListCompat getAdjustedDefault() {
         if (Build.VERSION.SDK_INT >= 24) {
             return LocaleListCompat.wrap(Api24Impl.getAdjustedDefault());
         } else {
@@ -222,8 +215,8 @@
      * called. This method takes that into account by always checking the output of
      * Locale.getDefault() and recalculating the default LocaleList if needed.</p>
      */
-    @NonNull @Size(min = 1)
-    public static LocaleListCompat getDefault() {
+    @Size(min = 1)
+    public static @NonNull LocaleListCompat getDefault() {
         if (Build.VERSION.SDK_INT >= 24) {
             return LocaleListCompat.wrap(Api24Impl.getDefault());
         } else {
@@ -319,9 +312,8 @@
         return mImpl.hashCode();
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return mImpl.toString();
     }
 
diff --git a/core/core/src/main/java/androidx/core/os/LocaleListCompatWrapper.java b/core/core/src/main/java/androidx/core/os/LocaleListCompatWrapper.java
index 09a6189..941cda3 100644
--- a/core/core/src/main/java/androidx/core/os/LocaleListCompatWrapper.java
+++ b/core/core/src/main/java/androidx/core/os/LocaleListCompatWrapper.java
@@ -19,11 +19,12 @@
 import android.os.Build;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -36,14 +37,12 @@
     // This is a comma-separated list of the locales in the LocaleListHelper created at construction
     // time, basically the result of running each locale's toLanguageTag() method and concatenating
     // them with commas in between.
-    @NonNull
-    private final String mStringRepresentation;
+    private final @NonNull String mStringRepresentation;
 
     private static final Locale[] sEmptyList = new Locale[0];
 
-    @Nullable
     @Override
-    public Object getLocaleList() {
+    public @Nullable Object getLocaleList() {
         return null;
     }
 
@@ -101,9 +100,8 @@
         return result;
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append("[");
         for (int i = 0; i < mList.length; i++) {
@@ -121,7 +119,7 @@
         return mStringRepresentation;
     }
 
-    LocaleListCompatWrapper(@NonNull Locale... list) {
+    LocaleListCompatWrapper(Locale @NonNull ... list) {
         if (list.length == 0) {
             mList = sEmptyList;
             mStringRepresentation = "";
@@ -265,7 +263,7 @@
     }
 
     @Override
-    public Locale getFirstMatch(@NonNull String[] supportedLocales) {
+    public Locale getFirstMatch(String @NonNull [] supportedLocales) {
         return computeFirstMatch(Arrays.asList(supportedLocales),
                 false /* assume English is not supported */);
     }
diff --git a/core/core/src/main/java/androidx/core/os/LocaleListInterface.java b/core/core/src/main/java/androidx/core/os/LocaleListInterface.java
index 4b9a313..a31535c 100644
--- a/core/core/src/main/java/androidx/core/os/LocaleListInterface.java
+++ b/core/core/src/main/java/androidx/core/os/LocaleListInterface.java
@@ -17,8 +17,9 @@
 package androidx.core.os;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.Locale;
 
@@ -37,6 +38,5 @@
 
     String toLanguageTags();
 
-    @Nullable
-    Locale getFirstMatch(@NonNull String[] supportedLocales);
+    @Nullable Locale getFirstMatch(String @NonNull [] supportedLocales);
 }
diff --git a/core/core/src/main/java/androidx/core/os/LocaleListPlatformWrapper.java b/core/core/src/main/java/androidx/core/os/LocaleListPlatformWrapper.java
index 0d96663..6231b04 100644
--- a/core/core/src/main/java/androidx/core/os/LocaleListPlatformWrapper.java
+++ b/core/core/src/main/java/androidx/core/os/LocaleListPlatformWrapper.java
@@ -18,10 +18,11 @@
 
 import android.os.LocaleList;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 
 @RequiresApi(24)
@@ -77,9 +78,8 @@
         return mLocaleList.toLanguageTags();
     }
 
-    @Nullable
     @Override
-    public Locale getFirstMatch(@NonNull String[] supportedLocales) {
+    public @Nullable Locale getFirstMatch(String @NonNull [] supportedLocales) {
         return mLocaleList.getFirstMatch(supportedLocales);
     }
 }
diff --git a/core/core/src/main/java/androidx/core/os/MessageCompat.java b/core/core/src/main/java/androidx/core/os/MessageCompat.java
index 7a09d89..00fe687 100644
--- a/core/core/src/main/java/androidx/core/os/MessageCompat.java
+++ b/core/core/src/main/java/androidx/core/os/MessageCompat.java
@@ -22,9 +22,10 @@
 import android.os.Message;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link Message}.
  */
diff --git a/core/core/src/main/java/androidx/core/os/OperationCanceledException.java b/core/core/src/main/java/androidx/core/os/OperationCanceledException.java
index b9d9e474..655b49c 100644
--- a/core/core/src/main/java/androidx/core/os/OperationCanceledException.java
+++ b/core/core/src/main/java/androidx/core/os/OperationCanceledException.java
@@ -17,9 +17,10 @@
 package androidx.core.os;
 
 
-import androidx.annotation.Nullable;
 import androidx.core.util.ObjectsCompat;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * An exception type that is thrown when an operation in progress is canceled.
  */
diff --git a/core/core/src/main/java/androidx/core/os/OutcomeReceiverCompat.java b/core/core/src/main/java/androidx/core/os/OutcomeReceiverCompat.java
index 7fb7387..cef9652 100644
--- a/core/core/src/main/java/androidx/core/os/OutcomeReceiverCompat.java
+++ b/core/core/src/main/java/androidx/core/os/OutcomeReceiverCompat.java
@@ -16,7 +16,7 @@
 
 package androidx.core.os;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Callback interface intended for use when an asynchronous operation may result in a failure.
diff --git a/core/core/src/main/java/androidx/core/os/ParcelCompat.java b/core/core/src/main/java/androidx/core/os/ParcelCompat.java
index 425a77ac..1150639 100644
--- a/core/core/src/main/java/androidx/core/os/ParcelCompat.java
+++ b/core/core/src/main/java/androidx/core/os/ParcelCompat.java
@@ -23,10 +23,11 @@
 import android.os.Parcelable;
 import android.util.SparseArray;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.Serializable;
 import java.lang.reflect.Array;
 import java.util.ArrayList;
@@ -98,9 +99,8 @@
      */
     @SuppressLint({"ConcreteCollection", "NullableCollection"})
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <T> ArrayList<T> readArrayList(@NonNull Parcel in, @Nullable ClassLoader loader,
-            @NonNull Class<? extends T> clazz) {
+    public static <T> @Nullable ArrayList<T> readArrayList(@NonNull Parcel in,
+            @Nullable ClassLoader loader, @NonNull Class<? extends T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readArrayList(in, loader, clazz);
         } else {
@@ -124,9 +124,8 @@
      */
     @SuppressWarnings({"deprecation", "unchecked"})
     @SuppressLint({"ArrayReturn", "NullableCollection"})
-    @Nullable
-    public static <T> Object[] readArray(@NonNull Parcel in, @Nullable ClassLoader loader,
-            @NonNull Class<T> clazz) {
+    public static <T> Object @Nullable [] readArray(@NonNull Parcel in,
+            @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readArray(in, loader, clazz);
         } else {
@@ -149,8 +148,7 @@
      * an error trying to instantiate an element.
      */
     @SuppressWarnings("deprecation")
-    @Nullable
-    public static <T> SparseArray<T> readSparseArray(@NonNull Parcel in,
+    public static <T> @Nullable SparseArray<T> readSparseArray(@NonNull Parcel in,
             @Nullable ClassLoader loader, @NonNull Class<? extends T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readSparseArray(in, loader, clazz);
@@ -198,9 +196,9 @@
      */
     @SuppressLint({"ConcreteCollection", "NullableCollection"})
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <K, V> HashMap<K, V> readHashMap(@NonNull Parcel in, @Nullable ClassLoader loader,
-            @NonNull Class<? extends K> clazzKey, @NonNull Class<? extends V> clazzValue) {
+    public static <K, V> @Nullable HashMap<K, V> readHashMap(@NonNull Parcel in,
+            @Nullable ClassLoader loader, @NonNull Class<? extends K> clazzKey,
+            @NonNull Class<? extends V> clazzValue) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readHashMap(in, loader, clazzKey, clazzValue);
         } else {
@@ -223,8 +221,7 @@
      * an error trying to instantiate an element.
      */
     @SuppressWarnings("deprecation")
-    @Nullable
-    public static <T extends Parcelable> T readParcelable(@NonNull Parcel in,
+    public static <T extends Parcelable> @Nullable T readParcelable(@NonNull Parcel in,
             @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readParcelable(in, loader, clazz);
@@ -254,9 +251,8 @@
      * there was an error trying to read the {@link Parcelable.Creator}.
      */
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
     @RequiresApi(30)
-    public static <T> Parcelable.Creator<T> readParcelableCreator(@NonNull Parcel in,
+    public static <T> Parcelable.@Nullable Creator<T> readParcelableCreator(@NonNull Parcel in,
             @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readParcelableCreator(in, loader, clazz);
@@ -284,10 +280,9 @@
      */
     @SuppressWarnings({"deprecation", "unchecked"})
     @SuppressLint({"ArrayReturn", "NullableCollection"})
-    @Nullable
     @Deprecated
-    public static <T> T[] readParcelableArray(@NonNull Parcel in, @Nullable ClassLoader loader,
-            @NonNull Class<T> clazz) {
+    public static <T> T @Nullable [] readParcelableArray(@NonNull Parcel in,
+            @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readParcelableArray(in, loader, clazz);
         } else {
@@ -328,8 +323,7 @@
      */
     @SuppressWarnings({"deprecation"})
     @SuppressLint({"ArrayReturn", "NullableCollection"})
-    @Nullable
-    public static <T> Parcelable[] readParcelableArrayTyped(@NonNull Parcel in,
+    public static <T> Parcelable @Nullable [] readParcelableArrayTyped(@NonNull Parcel in,
             @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return (Parcelable[]) Api33Impl.readParcelableArray(in, loader, clazz);
@@ -352,10 +346,9 @@
      * deserialized is not an instance of that class or any of its children classes or there was
      * an error trying to instantiate an element.
      */
-    @NonNull
     @SuppressWarnings({"deprecation", "unchecked"})
     @RequiresApi(api = Build.VERSION_CODES.Q)
-    public static <T> List<T> readParcelableList(@NonNull Parcel in, @NonNull List<T> list,
+    public static <T> @NonNull List<T> readParcelableList(@NonNull Parcel in, @NonNull List<T> list,
             @Nullable ClassLoader cl, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api33Impl.readParcelableList(in, list, cl, clazz);
@@ -380,8 +373,7 @@
      * was an error deserializing the object.
      */
     @SuppressWarnings({"deprecation", "unchecked"})
-    @Nullable
-    public static <T extends Serializable> T readSerializable(@NonNull Parcel in,
+    public static <T extends Serializable> @Nullable T readSerializable(@NonNull Parcel in,
             @Nullable ClassLoader loader, @NonNull Class<T> clazz) {
         if (Build.VERSION.SDK_INT >= 33) {
             return Api33Impl.readSerializable(in, loader, clazz);
diff --git a/core/core/src/main/java/androidx/core/os/TraceCompat.java b/core/core/src/main/java/androidx/core/os/TraceCompat.java
index 07074ec..7236a0c 100644
--- a/core/core/src/main/java/androidx/core/os/TraceCompat.java
+++ b/core/core/src/main/java/androidx/core/os/TraceCompat.java
@@ -17,9 +17,10 @@
 import android.os.Trace;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 
diff --git a/core/core/src/main/java/androidx/core/os/UserHandleCompat.java b/core/core/src/main/java/androidx/core/os/UserHandleCompat.java
index cfb78e3..5fdafcd 100644
--- a/core/core/src/main/java/androidx/core/os/UserHandleCompat.java
+++ b/core/core/src/main/java/androidx/core/os/UserHandleCompat.java
@@ -19,10 +19,11 @@
 import android.os.Build;
 import android.os.UserHandle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -33,10 +34,8 @@
  */
 public class UserHandleCompat {
 
-    @Nullable
-    private static Method sGetUserIdMethod;
-    @Nullable
-    private static Constructor<UserHandle> sUserHandleConstructor;
+    private static @Nullable Method sGetUserIdMethod;
+    private static @Nullable Constructor<UserHandle> sUserHandleConstructor;
 
     private UserHandleCompat() {
     }
@@ -44,8 +43,7 @@
     /**
      * Returns the user handle for a given uid.
      */
-    @NonNull
-    public static UserHandle getUserHandleForUid(int uid) {
+    public static @NonNull UserHandle getUserHandleForUid(int uid) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.getUserHandleForUid(uid);
         } else {
@@ -76,8 +74,7 @@
         private Api24Impl() {
         }
 
-        @NonNull
-        static UserHandle getUserHandleForUid(int uid) {
+        static @NonNull UserHandle getUserHandleForUid(int uid) {
             return UserHandle.getUserHandleForUid(uid);
         }
     }
diff --git a/core/core/src/main/java/androidx/core/os/UserManagerCompat.java b/core/core/src/main/java/androidx/core/os/UserManagerCompat.java
index 596f69a..55a435e 100644
--- a/core/core/src/main/java/androidx/core/os/UserManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/os/UserManagerCompat.java
@@ -20,9 +20,10 @@
 import android.os.Build;
 import android.os.UserManager;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link android.os.UserManager} in a backwards compatible
  * fashion.
diff --git a/core/core/src/main/java/androidx/core/provider/CallbackWrapper.java b/core/core/src/main/java/androidx/core/provider/CallbackWrapper.java
index 93e2e54..f3daf4b 100644
--- a/core/core/src/main/java/androidx/core/provider/CallbackWrapper.java
+++ b/core/core/src/main/java/androidx/core/provider/CallbackWrapper.java
@@ -20,10 +20,11 @@
 
 import android.graphics.Typeface;
 
-import androidx.annotation.NonNull;
 import androidx.core.provider.FontRequestWorker.TypefaceResult;
 import androidx.core.provider.FontsContractCompat.FontRequestCallback;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -33,8 +34,8 @@
  * If no Executor is provided, {@link CalleeHandler#create()} is used instead.
  */
 class CallbackWrapper {
-    @NonNull private final FontRequestCallback mCallback;
-    @NonNull private final Executor mExecutor;
+    private final @NonNull FontRequestCallback mCallback;
+    private final @NonNull Executor mExecutor;
 
     /**
      * Run callbacks in {@param executor}
@@ -57,7 +58,7 @@
     /**
      * Mirrors {@link FontRequestCallback#onTypefaceRetrieved(Typeface)}
      */
-    private void onTypefaceRetrieved(@NonNull final Typeface typeface) {
+    private void onTypefaceRetrieved(final @NonNull Typeface typeface) {
         final FontRequestCallback callback = this.mCallback;
         mExecutor.execute(new Runnable() {
             @Override
diff --git a/core/core/src/main/java/androidx/core/provider/CalleeHandler.java b/core/core/src/main/java/androidx/core/provider/CalleeHandler.java
index 68fec38..53ff539 100644
--- a/core/core/src/main/java/androidx/core/provider/CalleeHandler.java
+++ b/core/core/src/main/java/androidx/core/provider/CalleeHandler.java
@@ -20,7 +20,7 @@
 import android.os.Handler;
 import android.os.Looper;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 class CalleeHandler {
     private CalleeHandler() { }
@@ -30,8 +30,7 @@
      * If the current Thread has a Looper defined uses the current Thread
      * looper. Otherwise uses main Looper for the as the Handler.
      */
-    @NonNull
-    static Handler create() {
+    static @NonNull Handler create() {
         final Handler handler;
         if (Looper.myLooper() == null) {
             handler = new Handler(Looper.getMainLooper());
diff --git a/core/core/src/main/java/androidx/core/provider/DocumentsContractCompat.java b/core/core/src/main/java/androidx/core/provider/DocumentsContractCompat.java
index 11a631f..0d4e9ac 100644
--- a/core/core/src/main/java/androidx/core/provider/DocumentsContractCompat.java
+++ b/core/core/src/main/java/androidx/core/provider/DocumentsContractCompat.java
@@ -24,10 +24,11 @@
 import android.provider.DocumentsContract.Document;
 import android.provider.DocumentsProvider;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.FileNotFoundException;
 import java.util.List;
 
@@ -90,8 +91,7 @@
      *
      * @see DocumentsContract#getDocumentId(Uri)
      */
-    @Nullable
-    public static String getDocumentId(@NonNull Uri documentUri) {
+    public static @Nullable String getDocumentId(@NonNull Uri documentUri) {
         return DocumentsContract.getDocumentId(documentUri);
     }
 
@@ -100,8 +100,7 @@
      *
      * @see DocumentsContract#getTreeDocumentId(Uri)
      */
-    @Nullable
-    public static String getTreeDocumentId(@NonNull Uri documentUri) {
+    public static @Nullable String getTreeDocumentId(@NonNull Uri documentUri) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.getTreeDocumentId(documentUri);
         }
@@ -115,8 +114,8 @@
      *
      * @see DocumentsContract#buildDocumentUri(String, String)
      */
-    @Nullable
-    public static Uri buildDocumentUri(@NonNull String authority, @NonNull String documentId) {
+    public static @Nullable Uri buildDocumentUri(@NonNull String authority,
+            @NonNull String documentId) {
         return DocumentsContract.buildDocumentUri(authority, documentId);
     }
 
@@ -125,8 +124,8 @@
      * a document provider. When queried, a provider will return a single row
      * with columns defined by {@link Document}.
      */
-    @Nullable
-    public static Uri buildDocumentUriUsingTree(@NonNull Uri treeUri, @NonNull String documentId) {
+    public static @Nullable Uri buildDocumentUriUsingTree(@NonNull Uri treeUri,
+            @NonNull String documentId) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.buildDocumentUriUsingTree(treeUri, documentId);
         }
@@ -139,8 +138,8 @@
      *
      * @see DocumentsContract#buildTreeDocumentUri(String, String)
      */
-    @Nullable
-    public static Uri buildTreeDocumentUri(@NonNull String authority, @NonNull String documentId) {
+    public static @Nullable Uri buildTreeDocumentUri(@NonNull String authority,
+            @NonNull String documentId) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.buildTreeDocumentUri(authority, documentId);
         }
@@ -154,8 +153,7 @@
      *
      * @see DocumentsContract#buildChildDocumentsUri(String, String)
      */
-    @Nullable
-    public static Uri buildChildDocumentsUri(@NonNull String authority,
+    public static @Nullable Uri buildChildDocumentsUri(@NonNull String authority,
             @Nullable String parentDocumentId) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.buildChildDocumentsUri(authority, parentDocumentId);
@@ -170,8 +168,7 @@
      *
      * @see DocumentsContract#buildChildDocumentsUriUsingTree(Uri, String)
      */
-    @Nullable
-    public static Uri buildChildDocumentsUriUsingTree(@NonNull Uri treeUri,
+    public static @Nullable Uri buildChildDocumentsUriUsingTree(@NonNull Uri treeUri,
             @NonNull String parentDocumentId) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.buildChildDocumentsUriUsingTree(treeUri,
@@ -189,8 +186,7 @@
      * @param displayName       name of new document
      * @return newly created document, or {@code null} if failed
      */
-    @Nullable
-    public static Uri createDocument(@NonNull ContentResolver content,
+    public static @Nullable Uri createDocument(@NonNull ContentResolver content,
             @NonNull Uri parentDocumentUri, @NonNull String mimeType, @NonNull String displayName)
             throws FileNotFoundException {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
@@ -205,8 +201,7 @@
      *
      * @see DocumentsContract#renameDocument(ContentResolver, Uri, String)
      */
-    @Nullable
-    public static Uri renameDocument(@NonNull ContentResolver content,
+    public static @Nullable Uri renameDocument(@NonNull ContentResolver content,
             @NonNull Uri documentUri, @NonNull String displayName) throws FileNotFoundException {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             return DocumentsContractApi21Impl.renameDocument(content, documentUri, displayName);
diff --git a/core/core/src/main/java/androidx/core/provider/FontProvider.java b/core/core/src/main/java/androidx/core/provider/FontProvider.java
index 387b0ad..fe2d5c4 100644
--- a/core/core/src/main/java/androidx/core/provider/FontProvider.java
+++ b/core/core/src/main/java/androidx/core/provider/FontProvider.java
@@ -33,8 +33,6 @@
 import android.os.RemoteException;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 import androidx.collection.LruCache;
@@ -44,6 +42,9 @@
 import androidx.core.provider.FontsContractCompat.FontInfo;
 import androidx.tracing.Trace;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -54,8 +55,7 @@
 class FontProvider {
     private FontProvider() {}
 
-    @NonNull
-    static FontFamilyResult getFontFamilyResult(@NonNull Context context,
+    static @NonNull FontFamilyResult getFontFamilyResult(@NonNull Context context,
             @NonNull List<FontRequest> requests, @Nullable CancellationSignal cancellationSignal)
             throws PackageManager.NameNotFoundException {
         if (TypefaceCompat.DOWNLOADABLE_FONT_TRACING) {
@@ -132,8 +132,7 @@
      * Do not access directly, visible for testing only.
      */
     @VisibleForTesting
-    @Nullable
-    static ProviderInfo getProvider(
+    static @Nullable ProviderInfo getProvider(
             @NonNull PackageManager packageManager,
             @NonNull FontRequest request,
             @Nullable Resources resources
@@ -191,8 +190,7 @@
      * Do not access directly, visible for testing only.
      */
     @VisibleForTesting
-    @NonNull
-    static FontInfo[] query(
+    static FontInfo @NonNull [] query(
             Context context,
             FontRequest request,
             String authority,
diff --git a/core/core/src/main/java/androidx/core/provider/FontRequest.java b/core/core/src/main/java/androidx/core/provider/FontRequest.java
index 7088203..2c1af7a 100644
--- a/core/core/src/main/java/androidx/core/provider/FontRequest.java
+++ b/core/core/src/main/java/androidx/core/provider/FontRequest.java
@@ -22,11 +22,12 @@
 import android.util.Base64;
 
 import androidx.annotation.ArrayRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -97,8 +98,7 @@
      * Returns the selected font provider's authority. This tells the system what font provider
      * it should request the font from.
      */
-    @NonNull
-    public String getProviderAuthority() {
+    public @NonNull String getProviderAuthority() {
         return mProviderAuthority;
     }
 
@@ -106,8 +106,7 @@
      * Returns the selected font provider's package. This helps the system verify that the provider
      * identified by the given authority is the one requested.
      */
-    @NonNull
-    public String getProviderPackage() {
+    public @NonNull String getProviderPackage() {
         return mProviderPackage;
     }
 
@@ -115,8 +114,7 @@
      * Returns the query string. Refer to your font provider's documentation on the format of this
      * string.
      */
-    @NonNull
-    public String getQuery() {
+    public @NonNull String getQuery() {
         return mQuery;
     }
 
@@ -127,8 +125,7 @@
      *
      * @see #getCertificatesArrayResId()
      */
-    @Nullable
-    public List<List<byte[]>> getCertificates() {
+    public @Nullable List<List<byte[]>> getCertificates() {
         return mCertificates;
     }
 
@@ -156,8 +153,7 @@
     }
 
     @RestrictTo(LIBRARY)
-    @NonNull
-    String getId() {
+    @NonNull String getId() {
         return mIdentifier;
     }
 
diff --git a/core/core/src/main/java/androidx/core/provider/FontRequestWorker.java b/core/core/src/main/java/androidx/core/provider/FontRequestWorker.java
index 509de8d..3adff39 100644
--- a/core/core/src/main/java/androidx/core/provider/FontRequestWorker.java
+++ b/core/core/src/main/java/androidx/core/provider/FontRequestWorker.java
@@ -32,8 +32,6 @@
 import android.os.Process;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.collection.LruCache;
 import androidx.collection.SimpleArrayMap;
 import androidx.core.content.res.FontResourcesParserCompat;
@@ -43,6 +41,9 @@
 import androidx.core.util.Consumer;
 import androidx.tracing.Trace;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Callable;
@@ -99,9 +100,9 @@
      * @return
      */
     static Typeface requestFontSync(
-            @NonNull final Context context,
-            @NonNull final FontRequest request,
-            @NonNull final CallbackWrapper callback,
+            final @NonNull Context context,
+            final @NonNull FontRequest request,
+            final @NonNull CallbackWrapper callback,
             final int style,
             int timeoutInMillis
     ) {
@@ -161,11 +162,11 @@
      * @return
      */
     static Typeface requestFontAsync(
-            @NonNull final Context context,
-            @NonNull final List<FontRequest> requests,
+            final @NonNull Context context,
+            final @NonNull List<FontRequest> requests,
             final int style,
-            @Nullable final Executor executor,
-            @NonNull final CallbackWrapper callback
+            final @Nullable Executor executor,
+            final @NonNull CallbackWrapper callback
     ) {
         final String id = createCacheId(requests, style);
         Typeface cached = sTypefaceCache.get(id);
@@ -242,11 +243,10 @@
     }
 
     /** Package protected to prevent synthetic accessor */
-    @NonNull
-    static TypefaceResult getFontSync(
-            @NonNull final String cacheId,
-            @NonNull final Context context,
-            @NonNull final List<FontRequest> requests,
+    static @NonNull TypefaceResult getFontSync(
+            final @NonNull String cacheId,
+            final @NonNull Context context,
+            final @NonNull List<FontRequest> requests,
             int style
     ) {
         if (TypefaceCompat.DOWNLOADABLE_FONT_TRACING) {
diff --git a/core/core/src/main/java/androidx/core/provider/FontsContractCompat.java b/core/core/src/main/java/androidx/core/provider/FontsContractCompat.java
index b5e4dee..0aa23265 100644
--- a/core/core/src/main/java/androidx/core/provider/FontsContractCompat.java
+++ b/core/core/src/main/java/androidx/core/provider/FontsContractCompat.java
@@ -32,8 +32,6 @@
 
 import androidx.annotation.IntDef;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.content.res.ResourcesCompat;
@@ -41,6 +39,9 @@
 import androidx.core.graphics.TypefaceCompatUtil;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.nio.ByteBuffer;
@@ -77,11 +78,10 @@
      * @param fonts An array of {@link FontInfo} to be used to create a Typeface.
      * @return A Typeface object. Returns null if typeface creation fails.
      */
-    @Nullable
-    public static Typeface buildTypeface(
+    public static @Nullable Typeface buildTypeface(
             @NonNull Context context,
             @Nullable CancellationSignal cancellationSignal,
-            @NonNull FontInfo[] fonts
+            FontInfo @NonNull [] fonts
     ) {
         return TypefaceCompat.createFromFontInfo(context, cancellationSignal, fonts,
                 Typeface.NORMAL);
@@ -103,8 +103,7 @@
      * @throws PackageManager.NameNotFoundException If requested package or authority was not found
      *      in the system.
      */
-    @NonNull
-    public static FontFamilyResult fetchFonts(
+    public static @NonNull FontFamilyResult fetchFonts(
             @NonNull Context context,
             @Nullable CancellationSignal cancellationSignal,
             @NonNull FontRequest request
@@ -239,15 +238,14 @@
      *
      */
     @RestrictTo(LIBRARY)
-    @Nullable
-    public static Typeface requestFont(
-            @NonNull final Context context,
-            @NonNull final List<FontRequest> requests,
+    public static @Nullable Typeface requestFont(
+            final @NonNull Context context,
+            final @NonNull List<FontRequest> requests,
             @TypefaceStyle final int style,
             boolean isBlockingFetch,
             @IntRange(from = 0) int timeout,
-            @NonNull final Handler handler,
-            @NonNull final FontRequestCallback callback
+            final @NonNull Handler handler,
+            final @NonNull FontRequestCallback callback
     ) {
         CallbackWrapper callbackWrapper = new CallbackWrapper(
                 callback, RequestExecutor.createHandlerExecutor(handler));
@@ -290,15 +288,14 @@
      *
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public static Typeface requestFont(
-            @NonNull final Context context,
-            @NonNull final FontRequest request,
+    public static @Nullable Typeface requestFont(
+            final @NonNull Context context,
+            final @NonNull FontRequest request,
             @TypefaceStyle final int style,
             boolean isBlockingFetch,
             @IntRange(from = 0) int timeout,
-            @NonNull final Handler handler,
-            @NonNull final FontRequestCallback callback
+            final @NonNull Handler handler,
+            final @NonNull FontRequestCallback callback
     ) {
         return requestFont(context, List.of(request), style, isBlockingFetch, timeout, handler,
                 callback);
@@ -516,7 +513,7 @@
         // TODO after removing from public API make package private.
         @Deprecated
         @RestrictTo(LIBRARY_GROUP_PREFIX)
-        public FontFamilyResult(@FontResultStatus int statusCode, @Nullable FontInfo[] fonts) {
+        public FontFamilyResult(@FontResultStatus int statusCode, FontInfo @Nullable [] fonts) {
             mStatusCode = statusCode;
             mFonts = Collections.singletonList(fonts);
         }
@@ -547,15 +544,14 @@
         /**
          * Returns a list of arrays of fonts for each font family requested, in order.
          */
-        @NonNull
-        public List<FontInfo[]> getFontsWithFallbacks() {
+        public @NonNull List<FontInfo[]> getFontsWithFallbacks() {
             return mFonts;
         }
 
         @SuppressWarnings("deprecation")
         static FontFamilyResult create(
                 @FontResultStatus int statusCode,
-                @Nullable FontInfo[] fonts) {
+                FontInfo @Nullable [] fonts) {
             return new FontFamilyResult(statusCode, fonts);
         }
 
@@ -695,7 +691,7 @@
     public static Typeface getFontSync(
             final Context context,
             final FontRequest request,
-            final @Nullable ResourcesCompat.FontCallback fontCallback,
+            final ResourcesCompat.@Nullable FontCallback fontCallback,
             final @Nullable Handler handler,
             boolean isBlockingFetch,
             int timeout,
@@ -749,8 +745,7 @@
     @Deprecated // unused
     @VisibleForTesting
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public static ProviderInfo getProvider(
+    public static @Nullable ProviderInfo getProvider(
             @NonNull PackageManager packageManager,
             @NonNull FontRequest request,
             @Nullable Resources resources
diff --git a/core/core/src/main/java/androidx/core/provider/RequestExecutor.java b/core/core/src/main/java/androidx/core/provider/RequestExecutor.java
index 3820943..7fdcf33 100644
--- a/core/core/src/main/java/androidx/core/provider/RequestExecutor.java
+++ b/core/core/src/main/java/androidx/core/provider/RequestExecutor.java
@@ -20,10 +20,11 @@
 import android.os.Process;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
 import androidx.core.util.Consumer;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
@@ -59,7 +60,7 @@
 
     static <T> T submit(
             @NonNull ExecutorService executor,
-            @NonNull final Callable<T> callable,
+            final @NonNull Callable<T> callable,
             @IntRange(from = 0) int timeoutMillis
     ) throws InterruptedException {
         Future<T> future = executor.submit(callable);
diff --git a/core/core/src/main/java/androidx/core/service/quicksettings/PendingIntentActivityWrapper.java b/core/core/src/main/java/androidx/core/service/quicksettings/PendingIntentActivityWrapper.java
index d42dd7c..410031c 100644
--- a/core/core/src/main/java/androidx/core/service/quicksettings/PendingIntentActivityWrapper.java
+++ b/core/core/src/main/java/androidx/core/service/quicksettings/PendingIntentActivityWrapper.java
@@ -22,10 +22,11 @@
 import android.os.Bundle;
 import android.service.quicksettings.TileService;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.app.PendingIntentCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A wrapper class for developers to use with
  * {@link TileServiceCompat#startActivityAndCollapse(TileService, PendingIntentActivityWrapper)}.
@@ -36,17 +37,14 @@
 
     private final int mRequestCode;
 
-    @NonNull
-    private final Intent mIntent;
+    private final @NonNull Intent mIntent;
 
     @PendingIntentCompat.Flags
     private final int mFlags;
 
-    @Nullable
-    private final Bundle mOptions;
+    private final @Nullable Bundle mOptions;
 
-    @Nullable
-    private final PendingIntent mPendingIntent;
+    private final @Nullable PendingIntent mPendingIntent;
 
     private final boolean mIsMutable;
 
diff --git a/core/core/src/main/java/androidx/core/service/quicksettings/TileServiceCompat.java b/core/core/src/main/java/androidx/core/service/quicksettings/TileServiceCompat.java
index 436cd1a..bbc1a6e 100644
--- a/core/core/src/main/java/androidx/core/service/quicksettings/TileServiceCompat.java
+++ b/core/core/src/main/java/androidx/core/service/quicksettings/TileServiceCompat.java
@@ -22,10 +22,11 @@
 import android.content.Intent;
 import android.service.quicksettings.TileService;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A helper for accessing {@link TileService} API methods.
  */
diff --git a/core/core/src/main/java/androidx/core/telephony/TelephonyManagerCompat.java b/core/core/src/main/java/androidx/core/telephony/TelephonyManagerCompat.java
index e29978e..c3f6cf9 100644
--- a/core/core/src/main/java/androidx/core/telephony/TelephonyManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/telephony/TelephonyManagerCompat.java
@@ -23,11 +23,12 @@
 import android.os.Build.VERSION;
 import android.telephony.TelephonyManager;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 
@@ -64,8 +65,7 @@
      */
     @SuppressLint("MissingPermission")
     @RequiresPermission(android.Manifest.permission.READ_PHONE_STATE)
-    @Nullable
-    public static String getImei(@NonNull TelephonyManager telephonyManager) {
+    public static @Nullable String getImei(@NonNull TelephonyManager telephonyManager) {
         if (VERSION.SDK_INT >= 26) {
             return Api26Impl.getImei(telephonyManager);
         } else if (VERSION.SDK_INT >= 22) {
@@ -147,8 +147,7 @@
 
         @SuppressLint("MissingPermission")
         @RequiresPermission(android.Manifest.permission.READ_PHONE_STATE)
-        @Nullable
-        static String getImei(TelephonyManager telephonyManager) {
+        static @Nullable String getImei(TelephonyManager telephonyManager) {
             return telephonyManager.getImei();
         }
     }
@@ -159,8 +158,7 @@
 
         @SuppressLint("MissingPermission")
         @RequiresPermission(android.Manifest.permission.READ_PHONE_STATE)
-        @Nullable
-        static String getDeviceId(TelephonyManager telephonyManager, int slotIndex) {
+        static @Nullable String getDeviceId(TelephonyManager telephonyManager, int slotIndex) {
             return telephonyManager.getDeviceId(slotIndex);
         }
     }
diff --git a/core/core/src/main/java/androidx/core/telephony/mbms/MbmsHelper.java b/core/core/src/main/java/androidx/core/telephony/mbms/MbmsHelper.java
index 4977aa4..7f534fe 100644
--- a/core/core/src/main/java/androidx/core/telephony/mbms/MbmsHelper.java
+++ b/core/core/src/main/java/androidx/core/telephony/mbms/MbmsHelper.java
@@ -21,10 +21,11 @@
 import android.os.LocaleList;
 import android.telephony.mbms.ServiceInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 import java.util.Set;
 
@@ -49,8 +50,7 @@
      * @return The best name to display to the user for the service, or {@code null} if nothing
      * matches.
      */
-    @Nullable
-    public static CharSequence getBestNameForService(@NonNull Context context,
+    public static @Nullable CharSequence getBestNameForService(@NonNull Context context,
             @NonNull ServiceInfo serviceInfo) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.getBestNameForService(context, serviceInfo);
diff --git a/core/core/src/main/java/androidx/core/text/HtmlCompat.java b/core/core/src/main/java/androidx/core/text/HtmlCompat.java
index 930a601..1e2c667 100644
--- a/core/core/src/main/java/androidx/core/text/HtmlCompat.java
+++ b/core/core/src/main/java/androidx/core/text/HtmlCompat.java
@@ -31,11 +31,12 @@
 import android.text.style.ParagraphStyle;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 
 /**
@@ -141,8 +142,7 @@
      * ignored and {@link Html#fromHtml(String)} is used.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static Spanned fromHtml(@NonNull String source, @FromHtmlFlags int flags) {
+    public static @NonNull Spanned fromHtml(@NonNull String source, @FromHtmlFlags int flags) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.fromHtml(source, flags);
         }
@@ -155,8 +155,7 @@
      * {@link Html#fromHtml(String, ImageGetter, TagHandler)} is used.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static Spanned fromHtml(@NonNull String source, @FromHtmlFlags int flags,
+    public static @NonNull Spanned fromHtml(@NonNull String source, @FromHtmlFlags int flags,
             @Nullable ImageGetter imageGetter, @Nullable TagHandler tagHandler) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.fromHtml(source, flags, imageGetter, tagHandler);
@@ -169,8 +168,7 @@
      * ignored and {@link Html#toHtml(Spanned)} is used.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static String toHtml(@NonNull Spanned text, @ToHtmlOptions int options) {
+    public static @NonNull String toHtml(@NonNull Spanned text, @ToHtmlOptions int options) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.toHtml(text, options);
         }
diff --git a/core/core/src/main/java/androidx/core/text/ICUCompat.java b/core/core/src/main/java/androidx/core/text/ICUCompat.java
index b3b58c9..a12c2ce 100644
--- a/core/core/src/main/java/androidx/core/text/ICUCompat.java
+++ b/core/core/src/main/java/androidx/core/text/ICUCompat.java
@@ -21,10 +21,11 @@
 import android.os.Build;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.util.Locale;
@@ -82,8 +83,7 @@
      *
      * @return The script for a given Locale if ICU library is available, otherwise null.
      */
-    @Nullable
-    public static String maximizeAndGetScript(@NonNull Locale locale) {
+    public static @Nullable String maximizeAndGetScript(@NonNull Locale locale) {
         if (Build.VERSION.SDK_INT >= 24) {
             Object uLocale = Api24Impl.addLikelySubtags(Api24Impl.forLocale(locale));
             return Api24Impl.getScript(uLocale);
diff --git a/core/core/src/main/java/androidx/core/text/PrecomputedTextCompat.java b/core/core/src/main/java/androidx/core/text/PrecomputedTextCompat.java
index 34d6050c..9c0f43f 100644
--- a/core/core/src/main/java/androidx/core/text/PrecomputedTextCompat.java
+++ b/core/core/src/main/java/androidx/core/text/PrecomputedTextCompat.java
@@ -34,14 +34,15 @@
 
 import androidx.annotation.GuardedBy;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.UiThread;
 import androidx.core.util.ObjectsCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executor;
@@ -205,7 +206,7 @@
         }
 
         @RequiresApi(28)
-        public Params(@NonNull PrecomputedText.Params wrapped) {
+        public Params(PrecomputedText.@NonNull Params wrapped) {
             mPaint = wrapped.getTextPaint();
             mTextDir = wrapped.getTextDirection();
             mBreakStrategy = wrapped.getBreakStrategy();
@@ -386,7 +387,7 @@
     private final @NonNull Params mParams;
 
     // The list of measured paragraph info.
-    private final @NonNull int[] mParagraphEnds;
+    private final int @NonNull [] mParagraphEnds;
 
     // null on API 27 or before. Non-null on API 29 or later
     private final @Nullable PrecomputedText mWrapped;
@@ -466,7 +467,7 @@
 
     // Use PrecomputedText.create instead.
     private PrecomputedTextCompat(@NonNull CharSequence text, @NonNull Params params,
-            @NonNull int[] paraEnds) {
+            int @NonNull [] paraEnds) {
         mText = new SpannableString(text);
         mParams = params;
         mParagraphEnds = paraEnds;
@@ -544,8 +545,8 @@
             private PrecomputedTextCompat.Params mParams;
             private CharSequence mText;
 
-            PrecomputedTextCallback(@NonNull final PrecomputedTextCompat.Params params,
-                    @NonNull final CharSequence cs) {
+            PrecomputedTextCallback(final PrecomputedTextCompat.@NonNull Params params,
+                    final @NonNull CharSequence cs) {
                 mParams = params;
                 mText = cs;
             }
@@ -556,8 +557,8 @@
             }
         }
 
-        PrecomputedTextFutureTask(@NonNull final PrecomputedTextCompat.Params params,
-                @NonNull final CharSequence text) {
+        PrecomputedTextFutureTask(final PrecomputedTextCompat.@NonNull Params params,
+                final @NonNull CharSequence text) {
             super(new PrecomputedTextCallback(params, text));
         }
     }
@@ -623,7 +624,7 @@
      */
     @UiThread
     public static Future<PrecomputedTextCompat> getTextFuture(
-            @NonNull final CharSequence charSequence, @NonNull PrecomputedTextCompat.Params params,
+            final @NonNull CharSequence charSequence, PrecomputedTextCompat.@NonNull Params params,
             @Nullable Executor executor) {
         PrecomputedTextFutureTask task = new PrecomputedTextFutureTask(params, charSequence);
         if (executor == null) {
@@ -731,9 +732,8 @@
         return mText.subSequence(start, end);
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return mText.toString();
     }
 
diff --git a/core/core/src/main/java/androidx/core/text/TextUtilsCompat.java b/core/core/src/main/java/androidx/core/text/TextUtilsCompat.java
index bdcd81ee..c485666 100644
--- a/core/core/src/main/java/androidx/core/text/TextUtilsCompat.java
+++ b/core/core/src/main/java/androidx/core/text/TextUtilsCompat.java
@@ -18,10 +18,11 @@
 
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 
 /**
@@ -35,8 +36,7 @@
      * @param s the string to be encoded
      * @return the encoded string
      */
-    @NonNull
-    public static String htmlEncode(@NonNull String s) {
+    public static @NonNull String htmlEncode(@NonNull String s) {
         return TextUtils.htmlEncode(s);
     }
 
diff --git a/core/core/src/main/java/androidx/core/text/method/LinkMovementMethodCompat.java b/core/core/src/main/java/androidx/core/text/method/LinkMovementMethodCompat.java
index f0701b0..cd87a66 100644
--- a/core/core/src/main/java/androidx/core/text/method/LinkMovementMethodCompat.java
+++ b/core/core/src/main/java/androidx/core/text/method/LinkMovementMethodCompat.java
@@ -25,8 +25,8 @@
 import android.view.MotionEvent;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Backwards compatible version of {@link LinkMovementMethod} which fixes the issue that links can
@@ -81,8 +81,7 @@
      *
      * @return the singleton instance of {@link LinkMovementMethodCompat}
      */
-    @NonNull
-    public static LinkMovementMethodCompat getInstance() {
+    public static @NonNull LinkMovementMethodCompat getInstance() {
         if (sInstance == null) {
             sInstance = new LinkMovementMethodCompat();
         }
diff --git a/core/core/src/main/java/androidx/core/text/util/LinkifyCompat.java b/core/core/src/main/java/androidx/core/text/util/LinkifyCompat.java
index d5ddfc9..c509cb7 100644
--- a/core/core/src/main/java/androidx/core/text/util/LinkifyCompat.java
+++ b/core/core/src/main/java/androidx/core/text/util/LinkifyCompat.java
@@ -32,12 +32,13 @@
 import android.widget.TextView;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.PatternsCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.UnsupportedEncodingException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -242,7 +243,7 @@
      *  @param transformFilter Filter to allow the client code to update the link found.
      */
     public static void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
-            @Nullable String defaultScheme, @Nullable String[] schemes,
+            @Nullable String defaultScheme, String @Nullable [] schemes,
             @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
         if (shouldAddLinksFallbackToFramework()) {
             Api24Impl.addLinks(text, pattern, defaultScheme, schemes, matchFilter, transformFilter);
@@ -317,7 +318,7 @@
      * @return True if at least one link is found and applied.
      */
     public static boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
-            @Nullable  String defaultScheme, @Nullable String[] schemes,
+             @Nullable String defaultScheme, String @Nullable [] schemes,
             @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
         if (shouldAddLinksFallbackToFramework()) {
             return Api24Impl.addLinks(spannable, pattern, defaultScheme, schemes, matchFilter,
@@ -374,7 +375,7 @@
         }
     }
 
-    private static String makeUrl(@NonNull String url, @NonNull String[] prefixes,
+    private static String makeUrl(@NonNull String url, String @NonNull [] prefixes,
             Matcher matcher, @Nullable TransformFilter filter) {
         if (filter != null) {
             url = filter.transformUrl(matcher, url);
diff --git a/core/core/src/main/java/androidx/core/text/util/LocalePreferences.java b/core/core/src/main/java/androidx/core/text/util/LocalePreferences.java
index 846f4c2..6b727a8 100644
--- a/core/core/src/main/java/androidx/core/text/util/LocalePreferences.java
+++ b/core/core/src/main/java/androidx/core/text/util/LocalePreferences.java
@@ -24,11 +24,12 @@
 import android.os.Build;
 import android.os.Build.VERSION_CODES;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.StringDef;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.Arrays;
@@ -80,9 +81,8 @@
      * bases on the {@code Locale#getDefault(Locale.Category)}. It is one of the strings defined in
      * {@see HourCycle}, e.g. {@code HourCycle#H11}.
      */
-    @NonNull
     @HourCycle.HourCycleTypes
-    public static String getHourCycle() {
+    public static @NonNull String getHourCycle() {
         return getHourCycle(true);
     }
 
@@ -91,9 +91,8 @@
      * and based on the input {@code Locale}. It is one of the strings defined in
      * {@see HourCycle}, e.g. {@code HourCycle#H11}.
      */
-    @NonNull
     @HourCycle.HourCycleTypes
-    public static String getHourCycle(@NonNull Locale locale) {
+    public static @NonNull String getHourCycle(@NonNull Locale locale) {
         return getHourCycle(locale, true);
     }
 
@@ -113,9 +112,8 @@
      * in the hour cycle subtag, e.g. en-US-u-hc-h32, this function returns empty string, i.e.
      * {@code HourCycle#DEFAULT}.
      */
-    @NonNull
     @HourCycle.HourCycleTypes
-    public static String getHourCycle(
+    public static @NonNull String getHourCycle(
             boolean resolved) {
         Locale defaultLocale = (Build.VERSION.SDK_INT >= VERSION_CODES.N)
                 ? Api24Impl.getDefaultLocale()
@@ -137,9 +135,8 @@
      * in the hour cycle subtag, e.g. en-US-u-hc-h32, this function returns empty string, i.e.
      * {@code HourCycle#DEFAULT}.
      */
-    @NonNull
     @HourCycle.HourCycleTypes
-    public static String getHourCycle(@NonNull Locale locale, boolean resolved) {
+    public static @NonNull String getHourCycle(@NonNull Locale locale, boolean resolved) {
         String result = getUnicodeLocaleType(HourCycle.U_EXTENSION_TAG,
                 HourCycle.DEFAULT, locale, resolved);
         if (result != null) {
@@ -209,9 +206,8 @@
      * the {@code Locale#getDefault(Locale.Category)} settings. It is one of the strings defined in
      * {@see CalendarType}, e.g. {@code CalendarType#CHINESE}.
      */
-    @NonNull
     @CalendarType.CalendarTypes
-    public static String getCalendarType() {
+    public static @NonNull String getCalendarType() {
         return getCalendarType(true);
     }
 
@@ -220,9 +216,8 @@
      * based on the input {@link Locale} settings. It is one of the strings defined in
      * {@see CalendarType}, e.g. {@code CalendarType#CHINESE}.
      */
-    @NonNull
     @CalendarType.CalendarTypes
-    public static String getCalendarType(@NonNull Locale locale) {
+    public static @NonNull String getCalendarType(@NonNull Locale locale) {
         return getCalendarType(locale, true);
     }
 
@@ -243,9 +238,8 @@
      * specified in the calendar type subtag, e.g. en-US-u-ca-calendar, this function returns
      * empty string, i.e. {@code CalendarType#DEFAULT}.
      */
-    @NonNull
     @CalendarType.CalendarTypes
-    public static String getCalendarType(boolean resolved) {
+    public static @NonNull String getCalendarType(boolean resolved) {
         Locale defaultLocale = (Build.VERSION.SDK_INT >= VERSION_CODES.N)
                 ? Api24Impl.getDefaultLocale()
                 : getDefaultLocale();
@@ -266,9 +260,8 @@
      * specified in the calendar type subtag, e.g. en-US-u-ca-calendar, this function returns
      * empty string, i.e. {@code CalendarType#DEFAULT}.
      */
-    @NonNull
     @CalendarType.CalendarTypes
-    public static String getCalendarType(@NonNull Locale locale, boolean resolved) {
+    public static @NonNull String getCalendarType(@NonNull Locale locale, boolean resolved) {
         String result = getUnicodeLocaleType(CalendarType.U_EXTENSION_TAG,
                 CalendarType.DEFAULT, locale, resolved);
         if (result != null) {
@@ -314,9 +307,8 @@
      * {@code Locale#getDefault(Locale.Category)} settings. It is one of the strings defined in
      * {@see TemperatureUnit}, e.g. {@code TemperatureUnit#FAHRENHEIT}.
      */
-    @NonNull
     @TemperatureUnit.TemperatureUnits
-    public static String getTemperatureUnit() {
+    public static @NonNull String getTemperatureUnit() {
         return getTemperatureUnit(true);
     }
 
@@ -324,9 +316,8 @@
      * Return the temperature unit of the inputted {@link Locale}. It is one of the strings
      * defined in {@see TemperatureUnit}, e.g. {@code TemperatureUnit#FAHRENHEIT}.
      */
-    @NonNull
     @TemperatureUnit.TemperatureUnits
-    public static String getTemperatureUnit(
+    public static @NonNull String getTemperatureUnit(
             @NonNull Locale locale) {
         return getTemperatureUnit(locale, true);
     }
@@ -348,9 +339,8 @@
      * specified in the temperature unit subtag, e.g. en-US-u-mu-temperature, this function returns
      * empty string, i.e. {@code TemperatureUnit#DEFAULT}.
      */
-    @NonNull
     @TemperatureUnit.TemperatureUnits
-    public static String getTemperatureUnit(boolean resolved) {
+    public static @NonNull String getTemperatureUnit(boolean resolved) {
         Locale defaultLocale = (Build.VERSION.SDK_INT >= VERSION_CODES.N)
                 ? Api24Impl.getDefaultLocale()
                 : getDefaultLocale();
@@ -372,9 +362,8 @@
      * specified in the temperature unit subtag, e.g. en-US-u-mu-temperature, this function returns
      * empty string, i.e. {@code TemperatureUnit#DEFAULT}.
      */
-    @NonNull
     @TemperatureUnit.TemperatureUnits
-    public static String getTemperatureUnit(@NonNull Locale locale, boolean resolved) {
+    public static @NonNull String getTemperatureUnit(@NonNull Locale locale, boolean resolved) {
         String result = getUnicodeLocaleType(TemperatureUnit.U_EXTENSION_TAG,
                 TemperatureUnit.DEFAULT, locale, resolved);
         if (result != null) {
@@ -432,9 +421,8 @@
      * {@code Locale#getDefault(Locale.Category)} settings. It is one of the strings defined in
      * {@see FirstDayOfWeek}, e.g. {@code FirstDayOfWeek#SUNDAY}.
      */
-    @NonNull
     @FirstDayOfWeek.Days
-    public static String getFirstDayOfWeek() {
+    public static @NonNull String getFirstDayOfWeek() {
         return getFirstDayOfWeek(true);
     }
 
@@ -443,9 +431,8 @@
      * and based on the input {@code Locale} settings. It is one of the strings defined in
      * {@see FirstDayOfWeek}, e.g. {@code FirstDayOfWeek#SUNDAY}.
      */
-    @NonNull
     @FirstDayOfWeek.Days
-    public static String getFirstDayOfWeek(@NonNull Locale locale) {
+    public static @NonNull String getFirstDayOfWeek(@NonNull Locale locale) {
         return getFirstDayOfWeek(locale, true);
     }
 
@@ -466,9 +453,8 @@
      * in the first day of week subtag, e.g. en-US-u-fw-days, this function returns empty string,
      * i.e. {@code FirstDayOfWeek#DEFAULT}.
      */
-    @NonNull
     @FirstDayOfWeek.Days
-    public static String getFirstDayOfWeek(boolean resolved) {
+    public static @NonNull String getFirstDayOfWeek(boolean resolved) {
         Locale defaultLocale = (Build.VERSION.SDK_INT >= VERSION_CODES.N)
                 ? Api24Impl.getDefaultLocale()
                 : getDefaultLocale();
@@ -491,9 +477,8 @@
      * specified in the first day of week subtag, e.g. en-US-u-fw-days, this function returns
      * empty string, i.e. {@code FirstDayOfWeek#DEFAULT}.
      */
-    @NonNull
     @FirstDayOfWeek.Days
-    public static String getFirstDayOfWeek(
+    public static @NonNull String getFirstDayOfWeek(
             @NonNull Locale locale, boolean resolved) {
         String result = getUnicodeLocaleType(FirstDayOfWeek.U_EXTENSION_TAG,
                 FirstDayOfWeek.DEFAULT, locale, resolved);
diff --git a/core/core/src/main/java/androidx/core/util/AtomicFile.java b/core/core/src/main/java/androidx/core/util/AtomicFile.java
index 7310972..129115e 100644
--- a/core/core/src/main/java/androidx/core/util/AtomicFile.java
+++ b/core/core/src/main/java/androidx/core/util/AtomicFile.java
@@ -18,8 +18,8 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -63,8 +63,7 @@
      * Return the path to the base file.  You should not generally use this,
      * as the data at that path may not be valid.
      */
-    @NonNull
-    public File getBaseFile() {
+    public @NonNull File getBaseFile() {
         return mBaseName;
     }
 
@@ -91,8 +90,7 @@
      * safe (or will be lost).  You must do your own threading protection for
      * access to AtomicFile.
      */
-    @NonNull
-    public FileOutputStream startWrite() throws IOException {
+    public @NonNull FileOutputStream startWrite() throws IOException {
         if (mLegacyBackupName.exists()) {
             rename(mLegacyBackupName, mBaseName);
         }
@@ -161,8 +159,7 @@
      * <p>
      * You must do your own threading protection for access to AtomicFile.
      */
-    @NonNull
-    public FileInputStream openRead() throws FileNotFoundException {
+    public @NonNull FileInputStream openRead() throws FileNotFoundException {
         if (mLegacyBackupName.exists()) {
             rename(mLegacyBackupName, mBaseName);
         }
@@ -186,8 +183,7 @@
      * A convenience for {@link #openRead()} that also reads all of the
      * file contents into a byte array which is returned.
      */
-    @NonNull
-    public byte[] readFully() throws IOException {
+    public byte @NonNull [] readFully() throws IOException {
         FileInputStream stream = openRead();
         try {
             int pos = 0;
diff --git a/core/core/src/main/java/androidx/core/util/ObjectsCompat.java b/core/core/src/main/java/androidx/core/util/ObjectsCompat.java
index b7b9a03..9d65e12 100644
--- a/core/core/src/main/java/androidx/core/util/ObjectsCompat.java
+++ b/core/core/src/main/java/androidx/core/util/ObjectsCompat.java
@@ -15,8 +15,8 @@
  */
 package androidx.core.util;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.Arrays;
 import java.util.Objects;
@@ -84,7 +84,7 @@
      * @return a hash value of the sequence of input values
      * @see Arrays#hashCode(Object[])
      */
-    public static int hash(@Nullable Object... values) {
+    public static int hash(Object @Nullable ... values) {
         return Objects.hash(values);
     }
 
@@ -97,8 +97,7 @@
      * @return the result of calling {@code toString} on the first argument if it is not {@code
      * null} and the second argument otherwise.
      */
-    @Nullable
-    public static String toString(@Nullable Object o, @Nullable String nullDefault) {
+    public static @Nullable String toString(@Nullable Object o, @Nullable String nullDefault) {
         return (o != null) ? o.toString() : nullDefault;
     }
 
@@ -117,8 +116,7 @@
      * @return {@code obj} if not {@code null}
      * @throws NullPointerException if {@code obj} is {@code null}
      */
-    @NonNull
-    public static <T> T requireNonNull(@Nullable T obj) {
+    public static <T> @NonNull T requireNonNull(@Nullable T obj) {
         if (obj == null) throw new NullPointerException();
         return obj;
     }
@@ -142,8 +140,7 @@
      * @return {@code obj} if not {@code null}
      * @throws NullPointerException if {@code obj} is {@code null}
      */
-    @NonNull
-    public static <T> T requireNonNull(@Nullable T obj, @NonNull String message) {
+    public static <T> @NonNull T requireNonNull(@Nullable T obj, @NonNull String message) {
         if (obj == null) throw new NullPointerException(message);
         return obj;
     }
diff --git a/core/core/src/main/java/androidx/core/util/Pair.java b/core/core/src/main/java/androidx/core/util/Pair.java
index 00840d5..75dbc53 100644
--- a/core/core/src/main/java/androidx/core/util/Pair.java
+++ b/core/core/src/main/java/androidx/core/util/Pair.java
@@ -16,7 +16,7 @@
 
 package androidx.core.util;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Container to ease passing around a tuple of two objects. This object provides a sensible
@@ -66,9 +66,8 @@
         return (first == null ? 0 : first.hashCode()) ^ (second == null ? 0 : second.hashCode());
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return "Pair{" + first + " " + second + "}";
     }
 
@@ -78,9 +77,8 @@
      * @param b the second object in the pair
      * @return a Pair that is templatized with the types of a and b
      */
-    @NonNull
     @SuppressWarnings("UnknownNullness") // Generic nullness should come from type annotations.
-    public static <A, B> Pair<A, B> create(A a, B b) {
+    public static <A, B> @NonNull Pair<A, B> create(A a, B b) {
         return new Pair<>(a, b);
     }
 }
diff --git a/core/core/src/main/java/androidx/core/util/Preconditions.java b/core/core/src/main/java/androidx/core/util/Preconditions.java
index a975b9e7..aeeb020 100644
--- a/core/core/src/main/java/androidx/core/util/Preconditions.java
+++ b/core/core/src/main/java/androidx/core/util/Preconditions.java
@@ -19,10 +19,11 @@
 import android.text.TextUtils;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 
 /**
@@ -64,7 +65,7 @@
     public static void checkArgument(
             final boolean expression,
             final @NonNull String messageTemplate,
-            final @NonNull Object... messageArgs) {
+            final Object @NonNull ... messageArgs) {
         if (!expression) {
             throw new IllegalArgumentException(String.format(messageTemplate, messageArgs));
         }
@@ -78,8 +79,8 @@
      * @return the string reference that was validated
      * @throws IllegalArgumentException if {@code string} is empty
      */
-    public static @NonNull <T extends CharSequence> T checkStringNotEmpty(
-            @Nullable final T string) {
+    public static <T extends CharSequence> @NonNull T checkStringNotEmpty(
+            final @Nullable T string) {
         if (TextUtils.isEmpty(string)) {
             throw new IllegalArgumentException();
         }
@@ -96,8 +97,8 @@
      * @return the string reference that was validated
      * @throws IllegalArgumentException if {@code string} is empty
      */
-    public static @NonNull <T extends CharSequence> T checkStringNotEmpty(
-            @Nullable final T string, @NonNull final Object errorMessage) {
+    public static <T extends CharSequence> @NonNull T checkStringNotEmpty(
+            final @Nullable T string, final @NonNull Object errorMessage) {
         if (TextUtils.isEmpty(string)) {
             throw new IllegalArgumentException(String.valueOf(errorMessage));
         }
@@ -114,9 +115,9 @@
      * @return the string reference that was validated
      * @throws IllegalArgumentException if {@code string} is empty
      */
-    public static @NonNull <T extends CharSequence> T checkStringNotEmpty(
-            @Nullable final T string, @NonNull final String messageTemplate,
-            @NonNull final Object... messageArgs) {
+    public static <T extends CharSequence> @NonNull T checkStringNotEmpty(
+            final @Nullable T string, final @NonNull String messageTemplate,
+            final Object @NonNull ... messageArgs) {
         if (TextUtils.isEmpty(string)) {
             throw new IllegalArgumentException(String.format(messageTemplate, messageArgs));
         }
@@ -131,7 +132,7 @@
      * @return the non-null reference that was validated
      * @throws NullPointerException if {@code reference} is null
      */
-    public static @NonNull <T> T checkNotNull(@Nullable T reference) {
+    public static <T> @NonNull T checkNotNull(@Nullable T reference) {
         if (reference == null) {
             throw new NullPointerException();
         }
@@ -148,7 +149,7 @@
      * @return the non-null reference that was validated
      * @throws NullPointerException if {@code reference} is null
      */
-    public static @NonNull <T> T checkNotNull(@Nullable T reference, @NonNull Object errorMessage) {
+    public static <T> @NonNull T checkNotNull(@Nullable T reference, @NonNull Object errorMessage) {
         if (reference == null) {
             throw new NullPointerException(String.valueOf(errorMessage));
         }
diff --git a/core/core/src/main/java/androidx/core/util/SizeFCompat.java b/core/core/src/main/java/androidx/core/util/SizeFCompat.java
index 4c6e307..e201b9e 100644
--- a/core/core/src/main/java/androidx/core/util/SizeFCompat.java
+++ b/core/core/src/main/java/androidx/core/util/SizeFCompat.java
@@ -18,9 +18,10 @@
 
 import android.util.SizeF;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Immutable class for describing width and height dimensions in some arbitrary unit. Width and
  * height are finite values stored as a floating point representation.
@@ -66,36 +67,31 @@
         return Float.floatToIntBits(mWidth) ^ Float.floatToIntBits(mHeight);
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return mWidth + "x" + mHeight;
     }
 
     /** Converts this {@link SizeFCompat} into a {@link SizeF}. */
     @RequiresApi(21)
-    @NonNull
-    public SizeF toSizeF() {
+    public @NonNull SizeF toSizeF() {
         return Api21Impl.toSizeF(this);
     }
 
     /** Converts this {@link SizeF} into a {@link SizeFCompat}. */
     @RequiresApi(21)
-    @NonNull
-    public static SizeFCompat toSizeFCompat(@NonNull SizeF size) {
+    public static @NonNull SizeFCompat toSizeFCompat(@NonNull SizeF size) {
         return Api21Impl.toSizeFCompat(size);
     }
 
     @RequiresApi(21)
     private static final class Api21Impl {
-        @NonNull
-        static SizeFCompat toSizeFCompat(@NonNull SizeF size) {
+        static @NonNull SizeFCompat toSizeFCompat(@NonNull SizeF size) {
             Preconditions.checkNotNull(size);
             return new SizeFCompat(size.getWidth(), size.getHeight());
         }
 
-        @NonNull
-        static SizeF toSizeF(@NonNull SizeFCompat size) {
+        static @NonNull SizeF toSizeF(@NonNull SizeFCompat size) {
             Preconditions.checkNotNull(size);
             return new SizeF(size.getWidth(), size.getHeight());
         }
diff --git a/core/core/src/main/java/androidx/core/util/TypedValueCompat.java b/core/core/src/main/java/androidx/core/util/TypedValueCompat.java
index 614f50a..4f8ec11 100644
--- a/core/core/src/main/java/androidx/core/util/TypedValueCompat.java
+++ b/core/core/src/main/java/androidx/core/util/TypedValueCompat.java
@@ -29,10 +29,11 @@
 import android.util.TypedValue;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/view/AccessibilityDelegateCompat.java b/core/core/src/main/java/androidx/core/view/AccessibilityDelegateCompat.java
index da8f831..1794741 100644
--- a/core/core/src/main/java/androidx/core/view/AccessibilityDelegateCompat.java
+++ b/core/core/src/main/java/androidx/core/view/AccessibilityDelegateCompat.java
@@ -30,8 +30,6 @@
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeProvider;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.R;
 import androidx.core.view.accessibility.AccessibilityClickableSpanCompat;
@@ -39,6 +37,9 @@
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
 import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 import java.util.Collections;
 import java.util.List;
@@ -320,8 +321,8 @@
      *
      * @see AccessibilityNodeProviderCompat
      */
-    @Nullable
-    public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(@NonNull View host) {
+    public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(
+            @NonNull View host) {
         Object provider = mOriginalDelegate.getAccessibilityNodeProvider(host);
         if (provider != null) {
             return new AccessibilityNodeProviderCompat(provider);
diff --git a/core/core/src/main/java/androidx/core/view/ActionProvider.java b/core/core/src/main/java/androidx/core/view/ActionProvider.java
index 341375b..dfa698d 100644
--- a/core/core/src/main/java/androidx/core/view/ActionProvider.java
+++ b/core/core/src/main/java/androidx/core/view/ActionProvider.java
@@ -24,10 +24,11 @@
 import android.view.SubMenu;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * This class is a mediator for accomplishing a given task, for example sharing a file. It is
  * responsible for creating a view that performs an action that accomplishes the task. This class
@@ -139,8 +140,7 @@
     /**
      * Gets the context associated with this action provider.
      */
-    @NonNull
-    public Context getContext() {
+    public @NonNull Context getContext() {
         return mContext;
     }
 
@@ -149,8 +149,7 @@
      *
      * @return A new action view.
      */
-    @NonNull
-    public abstract View onCreateActionView();
+    public abstract @NonNull View onCreateActionView();
 
     /**
      * Factory method called by the Android framework to create new action views.
@@ -165,8 +164,7 @@
      * @return the new action view
      */
     @SuppressWarnings("unused")
-    @NonNull
-    public View onCreateActionView(@NonNull MenuItem forItem) {
+    public @NonNull View onCreateActionView(@NonNull MenuItem forItem) {
         return onCreateActionView();
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/ContentInfoCompat.java b/core/core/src/main/java/androidx/core/view/ContentInfoCompat.java
index f789419..0c0a190 100644
--- a/core/core/src/main/java/androidx/core/view/ContentInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ContentInfoCompat.java
@@ -25,12 +25,13 @@
 import android.view.ContentInfo;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -98,8 +99,7 @@
      *
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
-    @NonNull
-    static String sourceToString(@Source int source) {
+    static @NonNull String sourceToString(@Source int source) {
         switch (source) {
             case SOURCE_APP: return "SOURCE_APP";
             case SOURCE_CLIPBOARD: return "SOURCE_CLIPBOARD";
@@ -132,16 +132,14 @@
      *
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
-    @NonNull
-    static String flagsToString(@Flags int flags) {
+    static @NonNull String flagsToString(@Flags int flags) {
         if ((flags & FLAG_CONVERT_TO_PLAIN_TEXT) != 0) {
             return "FLAG_CONVERT_TO_PLAIN_TEXT";
         }
         return String.valueOf(flags);
     }
 
-    @NonNull
-    private final Compat mCompat;
+    private final @NonNull Compat mCompat;
 
     ContentInfoCompat(@NonNull Compat compat) {
         mCompat = compat;
@@ -157,8 +155,8 @@
      * @return wrapped class
      */
     @RequiresApi(31)
-    @NonNull
-    public static ContentInfoCompat toContentInfoCompat(@NonNull ContentInfo platContentInfo) {
+    public static @NonNull ContentInfoCompat toContentInfoCompat(
+            @NonNull ContentInfo platContentInfo) {
         return new ContentInfoCompat(new Compat31Impl(platContentInfo));
     }
 
@@ -172,22 +170,19 @@
      * @see ContentInfoCompat#toContentInfoCompat
      */
     @RequiresApi(31)
-    @NonNull
-    public ContentInfo toContentInfo() {
+    public @NonNull ContentInfo toContentInfo() {
         return Objects.requireNonNull(mCompat.getWrapped());
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return mCompat.toString();
     }
 
     /**
      * The data to be inserted.
      */
-    @NonNull
-    public ClipData getClip() {
+    public @NonNull ClipData getClip() {
         return mCompat.getClip();
     }
 
@@ -214,8 +209,7 @@
      * {@link android.view.inputmethod.InputContentInfo#getLinkUri linkUri} was passed by the
      * IME.
      */
-    @Nullable
-    public Uri getLinkUri() {
+    public @Nullable Uri getLinkUri() {
         return mCompat.getLinkUri();
     }
 
@@ -224,8 +218,7 @@
      * include the {@link android.view.inputmethod.InputConnection#commitContent opts} passed by
      * the IME.
      */
-    @Nullable
-    public Bundle getExtras() {
+    public @Nullable Bundle getExtras() {
         return mCompat.getExtras();
     }
 
@@ -245,9 +238,8 @@
      * second object will have the content that didn't match the predicate, or null if all of
      * the items matched.
      */
-    @NonNull
-    public Pair<ContentInfoCompat, ContentInfoCompat> partition(
-            @NonNull androidx.core.util.Predicate<ClipData.Item> itemPredicate) {
+    public @NonNull Pair<ContentInfoCompat, ContentInfoCompat> partition(
+            androidx.core.util.@NonNull Predicate<ClipData.Item> itemPredicate) {
         ClipData clip = mCompat.getClip();
         if (clip.getItemCount() == 1) {
             boolean matched = itemPredicate.test(clip.getItemAt(0));
@@ -264,9 +256,8 @@
                 new ContentInfoCompat.Builder(this).setClip(split.second).build());
     }
 
-    @NonNull
-    static Pair<ClipData, ClipData> partition(@NonNull ClipData clip,
-            @NonNull androidx.core.util.Predicate<ClipData.Item> itemPredicate) {
+    static @NonNull Pair<ClipData, ClipData> partition(@NonNull ClipData clip,
+            androidx.core.util.@NonNull Predicate<ClipData.Item> itemPredicate) {
         ArrayList<ClipData.Item> acceptedItems = null;
         ArrayList<ClipData.Item> remainingItems = null;
         for (int i = 0; i < clip.getItemCount(); i++) {
@@ -290,8 +281,7 @@
                 buildClipData(clip.getDescription(), remainingItems));
     }
 
-    @NonNull
-    static ClipData buildClipData(@NonNull ClipDescription description,
+    static @NonNull ClipData buildClipData(@NonNull ClipDescription description,
             @NonNull List<ClipData.Item> items) {
         ClipData clip = new ClipData(new ClipDescription(description), items.get(0));
         for (int i = 1; i < items.size(); i++) {
@@ -318,8 +308,7 @@
      * the items matched.
      */
     @RequiresApi(31)
-    @NonNull
-    public static Pair<ContentInfo, ContentInfo> partition(@NonNull ContentInfo payload,
+    public static @NonNull Pair<ContentInfo, ContentInfo> partition(@NonNull ContentInfo payload,
             @NonNull Predicate<ClipData.Item> itemPredicate) {
         return Api31Impl.partition(payload, itemPredicate);
     }
@@ -328,9 +317,8 @@
     private static final class Api31Impl {
         private Api31Impl() {}
 
-        @NonNull
-        public static Pair<ContentInfo, ContentInfo> partition(@NonNull ContentInfo payload,
-                @NonNull Predicate<ClipData.Item> itemPredicate) {
+        public static @NonNull Pair<ContentInfo, ContentInfo> partition(
+                @NonNull ContentInfo payload, @NonNull Predicate<ClipData.Item> itemPredicate) {
             ClipData clip = payload.getClip();
             if (clip.getItemCount() == 1) {
                 boolean matched = itemPredicate.test(clip.getItemAt(0));
@@ -349,31 +337,24 @@
     }
 
     private interface Compat {
-        @Nullable
-        ContentInfo getWrapped();
-        @NonNull
-        ClipData getClip();
+        @Nullable ContentInfo getWrapped();
+        @NonNull ClipData getClip();
         @Source
         int getSource();
         @Flags
         int getFlags();
-        @Nullable
-        Uri getLinkUri();
-        @Nullable
-        Bundle getExtras();
+        @Nullable Uri getLinkUri();
+        @Nullable Bundle getExtras();
     }
 
     private static final class CompatImpl implements Compat {
-        @NonNull
-        private final ClipData mClip;
+        private final @NonNull ClipData mClip;
         @Source
         private final int mSource;
         @Flags
         private final int mFlags;
-        @Nullable
-        private final Uri mLinkUri;
-        @Nullable
-        private final Bundle mExtras;
+        private final @Nullable Uri mLinkUri;
+        private final @Nullable Bundle mExtras;
 
         CompatImpl(BuilderCompatImpl b) {
             mClip = Preconditions.checkNotNull(b.mClip);
@@ -384,15 +365,13 @@
             mExtras = b.mExtras;
         }
 
-        @Nullable
         @Override
-        public ContentInfo getWrapped() {
+        public @Nullable ContentInfo getWrapped() {
             return null;
         }
 
-        @NonNull
         @Override
-        public ClipData getClip() {
+        public @NonNull ClipData getClip() {
             return mClip;
         }
 
@@ -408,21 +387,18 @@
             return mFlags;
         }
 
-        @Nullable
         @Override
-        public Uri getLinkUri() {
+        public @Nullable Uri getLinkUri() {
             return mLinkUri;
         }
 
-        @Nullable
         @Override
-        public Bundle getExtras() {
+        public @Nullable Bundle getExtras() {
             return mExtras;
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             return "ContentInfoCompat{"
                     + "clip=" + mClip.getDescription()
                     + ", source=" + sourceToString(mSource)
@@ -435,22 +411,19 @@
 
     @RequiresApi(31)
     private static final class Compat31Impl implements Compat {
-        @NonNull
-        private final ContentInfo mWrapped;
+        private final @NonNull ContentInfo mWrapped;
 
         Compat31Impl(@NonNull ContentInfo wrapped) {
             mWrapped = Preconditions.checkNotNull(wrapped);
         }
 
-        @NonNull
         @Override
-        public ContentInfo getWrapped() {
+        public @NonNull ContentInfo getWrapped() {
             return mWrapped;
         }
 
-        @NonNull
         @Override
-        public ClipData getClip() {
+        public @NonNull ClipData getClip() {
             return mWrapped.getClip();
         }
 
@@ -466,21 +439,18 @@
             return mWrapped.getFlags();
         }
 
-        @Nullable
         @Override
-        public Uri getLinkUri() {
+        public @Nullable Uri getLinkUri() {
             return mWrapped.getLinkUri();
         }
 
-        @Nullable
         @Override
-        public Bundle getExtras() {
+        public @Nullable Bundle getExtras() {
             return mWrapped.getExtras();
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             return "ContentInfoCompat{" + mWrapped + "}";
         }
     }
@@ -489,8 +459,7 @@
      * Builder for {@link ContentInfoCompat}.
      */
     public static final class Builder {
-        @NonNull
-        private final BuilderCompat mBuilderCompat;
+        private final @NonNull BuilderCompat mBuilderCompat;
 
         /**
          * Creates a new builder initialized with the data from the given object (shallow copy).
@@ -523,8 +492,7 @@
          * @param clip The data to insert.
          * @return this builder
          */
-        @NonNull
-        public Builder setClip(@NonNull ClipData clip) {
+        public @NonNull Builder setClip(@NonNull ClipData clip) {
             mBuilderCompat.setClip(clip);
             return this;
         }
@@ -535,8 +503,7 @@
          * @param source The source of the operation. See {@code SOURCE_} constants.
          * @return this builder
          */
-        @NonNull
-        public Builder setSource(@Source int source) {
+        public @NonNull Builder setSource(@Source int source) {
             mBuilderCompat.setSource(source);
             return this;
         }
@@ -548,8 +515,7 @@
          *              behavior. See {@code FLAG_} constants.
          * @return this builder
          */
-        @NonNull
-        public Builder setFlags(@Flags int flags) {
+        public @NonNull Builder setFlags(@Flags int flags) {
             mBuilderCompat.setFlags(flags);
             return this;
         }
@@ -561,8 +527,7 @@
          * @param linkUri Optional http/https URI for the content.
          * @return this builder
          */
-        @NonNull
-        public Builder setLinkUri(@Nullable Uri linkUri) {
+        public @NonNull Builder setLinkUri(@Nullable Uri linkUri) {
             mBuilderCompat.setLinkUri(linkUri);
             return this;
         }
@@ -573,8 +538,7 @@
          * @param extras Optional bundle with additional metadata.
          * @return this builder
          */
-        @NonNull
-        public Builder setExtras(@Nullable Bundle extras) {
+        public @NonNull Builder setExtras(@Nullable Bundle extras) {
             mBuilderCompat.setExtras(extras);
             return this;
         }
@@ -582,8 +546,7 @@
         /**
          * @return A new {@link ContentInfoCompat} instance with the data from this builder.
          */
-        @NonNull
-        public ContentInfoCompat build() {
+        public @NonNull ContentInfoCompat build() {
             return mBuilderCompat.build();
         }
     }
@@ -594,21 +557,17 @@
         void setFlags(@Flags int flags);
         void setLinkUri(@Nullable Uri linkUri);
         void setExtras(@Nullable Bundle extras);
-        @NonNull
-        ContentInfoCompat build();
+        @NonNull ContentInfoCompat build();
     }
 
     private static final class BuilderCompatImpl implements BuilderCompat {
-        @NonNull
-        ClipData mClip;
+        @NonNull ClipData mClip;
         @Source
         int mSource;
         @Flags
         int mFlags;
-        @Nullable
-        Uri mLinkUri;
-        @Nullable
-        Bundle mExtras;
+        @Nullable Uri mLinkUri;
+        @Nullable Bundle mExtras;
 
         BuilderCompatImpl(@NonNull ClipData clip, int source) {
             mClip = clip;
@@ -649,16 +608,14 @@
         }
 
         @Override
-        @NonNull
-        public ContentInfoCompat build() {
+        public @NonNull ContentInfoCompat build() {
             return new ContentInfoCompat(new CompatImpl(this));
         }
     }
 
     @RequiresApi(31)
     private static final class BuilderCompat31Impl implements BuilderCompat {
-        @NonNull
-        private final ContentInfo.Builder mPlatformBuilder;
+        private final ContentInfo.@NonNull Builder mPlatformBuilder;
 
         BuilderCompat31Impl(@NonNull ClipData clip, int source) {
             mPlatformBuilder = new ContentInfo.Builder(clip, source);
@@ -693,9 +650,8 @@
             mPlatformBuilder.setExtras(extras);
         }
 
-        @NonNull
         @Override
-        public ContentInfoCompat build() {
+        public @NonNull ContentInfoCompat build() {
             return new ContentInfoCompat(new Compat31Impl(mPlatformBuilder.build()));
         }
     }
diff --git a/core/core/src/main/java/androidx/core/view/DifferentialMotionFlingController.java b/core/core/src/main/java/androidx/core/view/DifferentialMotionFlingController.java
index 335d66c..86a7517 100644
--- a/core/core/src/main/java/androidx/core/view/DifferentialMotionFlingController.java
+++ b/core/core/src/main/java/androidx/core/view/DifferentialMotionFlingController.java
@@ -21,10 +21,11 @@
 import android.view.VelocityTracker;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Controller differential motion flings.
  *
@@ -49,7 +50,7 @@
     private final FlingVelocityThresholdCalculator mVelocityThresholdCalculator;
     private final DifferentialVelocityProvider mVelocityProvider;
 
-    @Nullable private VelocityTracker mVelocityTracker;
+    private @Nullable VelocityTracker mVelocityTracker;
 
     private float mLastFlingVelocity;
 
diff --git a/core/core/src/main/java/androidx/core/view/DisplayCompat.java b/core/core/src/main/java/androidx/core/view/DisplayCompat.java
index 6629da5..e6c4ea4 100644
--- a/core/core/src/main/java/androidx/core/view/DisplayCompat.java
+++ b/core/core/src/main/java/androidx/core/view/DisplayCompat.java
@@ -27,11 +27,12 @@
 import android.text.TextUtils;
 import android.view.Display;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Method;
 
 /**
@@ -55,8 +56,7 @@
      * Gets the current display mode of the given display, where the size can be relied on to
      * determine support for 4k on Android TV devices.
      */
-    @NonNull
-    public static ModeCompat getMode(@NonNull Context context, @NonNull Display display) {
+    public static @NonNull ModeCompat getMode(@NonNull Context context, @NonNull Display display) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
             return Api23Impl.getMode(context, display);
         }
@@ -65,8 +65,8 @@
         return new ModeCompat(getDisplaySize(context, display));
     }
 
-    @NonNull
-    private static Point getDisplaySize(@NonNull Context context, @NonNull Display display) {
+    private static @NonNull Point getDisplaySize(@NonNull Context context,
+            @NonNull Display display) {
         // If a workaround for the display size is present, use it.
         Point displaySize = getCurrentDisplaySizeFromWorkarounds(context, display);
         if (displaySize != null) {
@@ -82,9 +82,8 @@
      * Gets the supported modes of the given display where any mode with the same size as the
      * current mode can be relied on to determine support for 4k on Android TV devices.
      */
-    @NonNull
     @SuppressLint("ArrayReturn")
-    public static ModeCompat[] getSupportedModes(
+    public static ModeCompat @NonNull [] getSupportedModes(
                 @NonNull Context context, @NonNull Display display) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
             return Api23Impl.getSupportedModes(context, display);
@@ -122,8 +121,7 @@
      * @param name the name of the system property
      * @return the result string or null if an exception occurred
      */
-    @Nullable
-    private static String getSystemProperty(String name) {
+    private static @Nullable String getSystemProperty(String name) {
         try {
             @SuppressLint("PrivateApi")
             Class<?> systemProperties = Class.forName("android.os.SystemProperties");
@@ -153,9 +151,8 @@
      *
      * @return the physical display size, in pixels or null if the information is not available
      */
-    @Nullable
-    private static Point parsePhysicalDisplaySizeFromSystemProperties(@NonNull String property,
-            @NonNull Display display) {
+    private static @Nullable Point parsePhysicalDisplaySizeFromSystemProperties(
+            @NonNull String property, @NonNull Display display) {
         // System properties are only relevant for the default display.
         if (display.getDisplayId() != Display.DEFAULT_DISPLAY) {
             return null;
@@ -228,8 +225,7 @@
     static class Api23Impl {
         private Api23Impl() {}
 
-        @NonNull
-        static ModeCompat getMode(@NonNull Context context, @NonNull Display display) {
+        static @NonNull ModeCompat getMode(@NonNull Context context, @NonNull Display display) {
             Display.Mode currentMode = display.getMode();
             Point workaroundSize = getCurrentDisplaySizeFromWorkarounds(context, display);
             // If the current mode has the wrong physical size, then correct it with the
@@ -239,9 +235,8 @@
                     : new ModeCompat(currentMode, workaroundSize);
         }
 
-        @NonNull
         @SuppressLint("ArrayReturn")
-        public static ModeCompat[] getSupportedModes(
+        public static ModeCompat @NonNull [] getSupportedModes(
                     @NonNull Context context, @NonNull Display display) {
             Display.Mode[] supportedModes = display.getSupportedModes();
             ModeCompat[] supportedModesCompat = new ModeCompat[supportedModes.length];
@@ -327,7 +322,7 @@
          * @param mode the wrapped Display.Mode object
          */
         @RequiresApi(Build.VERSION_CODES.M)
-        ModeCompat(@NonNull Display.Mode mode, boolean isNative) {
+        ModeCompat(Display.@NonNull Mode mode, boolean isNative) {
             Preconditions.checkNotNull(mode, "mode == null, can't wrap a null reference");
             // This simplifies the getPhysicalWidth() / getPhysicalHeight functions below
             mPhysicalSize = new Point(Api23Impl.getPhysicalWidth(mode),
@@ -345,7 +340,7 @@
          *
          */
         @RequiresApi(Build.VERSION_CODES.M)
-        ModeCompat(@NonNull Display.Mode mode, @NonNull Point physicalSize) {
+        ModeCompat(Display.@NonNull Mode mode, @NonNull Point physicalSize) {
             Preconditions.checkNotNull(mode, "mode == null, can't wrap a null reference");
             Preconditions.checkNotNull(physicalSize, "physicalSize == null");
             mPhysicalSize = physicalSize;
@@ -385,8 +380,7 @@
          * Returns the wrapped object Display.Mode, which may be null if no mode is available.
          */
         @RequiresApi(Build.VERSION_CODES.M)
-        @Nullable
-        public Display.Mode toMode() {
+        public Display.@Nullable Mode toMode() {
             return mMode;
         }
 
diff --git a/core/core/src/main/java/androidx/core/view/DisplayCutoutCompat.java b/core/core/src/main/java/androidx/core/view/DisplayCutoutCompat.java
index 0c4bd43..474fcff 100644
--- a/core/core/src/main/java/androidx/core/view/DisplayCutoutCompat.java
+++ b/core/core/src/main/java/androidx/core/view/DisplayCutoutCompat.java
@@ -23,17 +23,17 @@
 import android.os.Build;
 import android.view.DisplayCutout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.graphics.Insets;
 import androidx.core.util.ObjectsCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
-
 /**
  * Represents the area of the display that is not functional for displaying content.
  *
@@ -187,8 +187,7 @@
      *
      * @return a list of bounding {@code Rect}s, one for each display cutout area.
      */
-    @NonNull
-    public List<Rect> getBoundingRects() {
+    public @NonNull List<Rect> getBoundingRects() {
         if (SDK_INT >= 28) {
             return Api28Impl.getBoundingRects(mDisplayCutout);
         } else {
@@ -206,8 +205,7 @@
      * @return the insets for the curved areas of a waterfall display in pixels or {@code
      * Insets.NONE} if there are no curved areas or they don't overlap with the window.
      */
-    @NonNull
-    public Insets getWaterfallInsets() {
+    public @NonNull Insets getWaterfallInsets() {
         if (SDK_INT >= 30) {
             return Insets.toCompatInsets(Api30Impl.getWaterfallInsets(mDisplayCutout));
         } else {
@@ -223,8 +221,7 @@
      *
      * @return the path corresponding to the cutout, or null if there is no cutout on the display.
      */
-    @Nullable
-    public Path getCutoutPath() {
+    public @Nullable Path getCutoutPath() {
         if (SDK_INT >= 31) {
             return Api31Impl.getCutoutPath(mDisplayCutout);
         } else {
@@ -249,9 +246,8 @@
         return mDisplayCutout == null ? 0 : mDisplayCutout.hashCode();
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         return "DisplayCutoutCompat{" + mDisplayCutout + "}";
     }
 
@@ -331,8 +327,7 @@
             // This class is not instantiable.
         }
 
-        @Nullable
-        static Path getCutoutPath(DisplayCutout displayCutout) {
+        static @Nullable Path getCutoutPath(DisplayCutout displayCutout) {
             return displayCutout.getCutoutPath();
         }
     }
diff --git a/core/core/src/main/java/androidx/core/view/DragAndDropPermissionsCompat.java b/core/core/src/main/java/androidx/core/view/DragAndDropPermissionsCompat.java
index 316da25..7e65fbe 100644
--- a/core/core/src/main/java/androidx/core/view/DragAndDropPermissionsCompat.java
+++ b/core/core/src/main/java/androidx/core/view/DragAndDropPermissionsCompat.java
@@ -23,11 +23,12 @@
 import android.view.DragAndDropPermissions;
 import android.view.DragEvent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link DragAndDropPermissions} a backwards
  * compatible fashion.
@@ -46,8 +47,7 @@
     }
 
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public static DragAndDropPermissionsCompat request(@NonNull Activity activity,
+    public static @Nullable DragAndDropPermissionsCompat request(@NonNull Activity activity,
             @NonNull DragEvent dragEvent) {
         if (Build.VERSION.SDK_INT >= 24) {
             DragAndDropPermissions dragAndDropPermissions =
diff --git a/core/core/src/main/java/androidx/core/view/DragStartHelper.java b/core/core/src/main/java/androidx/core/view/DragStartHelper.java
index 6d86d81..37f1be2 100644
--- a/core/core/src/main/java/androidx/core/view/DragStartHelper.java
+++ b/core/core/src/main/java/androidx/core/view/DragStartHelper.java
@@ -21,7 +21,7 @@
 import android.view.MotionEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * DragStartHelper is a utility class for implementing drag and drop support.
diff --git a/core/core/src/main/java/androidx/core/view/GestureDetectorCompat.java b/core/core/src/main/java/androidx/core/view/GestureDetectorCompat.java
index 1d68424..56843f8 100644
--- a/core/core/src/main/java/androidx/core/view/GestureDetectorCompat.java
+++ b/core/core/src/main/java/androidx/core/view/GestureDetectorCompat.java
@@ -25,8 +25,8 @@
 import android.view.MotionEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Detects various gestures and events using the supplied {@link MotionEvent}s.
diff --git a/core/core/src/main/java/androidx/core/view/GravityCompat.java b/core/core/src/main/java/androidx/core/view/GravityCompat.java
index b7e1215..af4fada 100644
--- a/core/core/src/main/java/androidx/core/view/GravityCompat.java
+++ b/core/core/src/main/java/androidx/core/view/GravityCompat.java
@@ -20,7 +20,7 @@
 import android.graphics.Rect;
 import android.view.Gravity;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Compatibility shim for accessing newer functionality from {@link Gravity}.
diff --git a/core/core/src/main/java/androidx/core/view/KeyEventDispatcher.java b/core/core/src/main/java/androidx/core/view/KeyEventDispatcher.java
index 5605d46..511b1ad 100644
--- a/core/core/src/main/java/androidx/core/view/KeyEventDispatcher.java
+++ b/core/core/src/main/java/androidx/core/view/KeyEventDispatcher.java
@@ -26,10 +26,11 @@
 import android.view.View;
 import android.view.Window;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -77,7 +78,7 @@
      */
     @SuppressLint("LambdaLast")
     public static boolean dispatchKeyEvent(@NonNull Component component,
-            @Nullable View root, @Nullable Window.Callback callback, @NonNull KeyEvent event) {
+            @Nullable View root, Window.@Nullable Callback callback, @NonNull KeyEvent event) {
         if (component == null) {
             return false;
         }
diff --git a/core/core/src/main/java/androidx/core/view/LayoutInflaterCompat.java b/core/core/src/main/java/androidx/core/view/LayoutInflaterCompat.java
index 667cadd..26358e21 100644
--- a/core/core/src/main/java/androidx/core/view/LayoutInflaterCompat.java
+++ b/core/core/src/main/java/androidx/core/view/LayoutInflaterCompat.java
@@ -23,7 +23,7 @@
 import android.view.LayoutInflater;
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.lang.reflect.Field;
 
@@ -56,9 +56,8 @@
             return mDelegateFactory.onCreateView(parent, name, context, attributeSet);
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             return getClass().getName() + "{" + mDelegateFactory + "}";
         }
     }
@@ -137,7 +136,7 @@
      * @see LayoutInflater#setFactory2(android.view.LayoutInflater.Factory2)
      */
     public static void setFactory2(
-            @NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
+            @NonNull LayoutInflater inflater, LayoutInflater.@NonNull Factory2 factory) {
         inflater.setFactory2(factory);
 
         if (Build.VERSION.SDK_INT < 21) {
diff --git a/core/core/src/main/java/androidx/core/view/MarginLayoutParamsCompat.java b/core/core/src/main/java/androidx/core/view/MarginLayoutParamsCompat.java
index 876ca0b..b651f3f 100644
--- a/core/core/src/main/java/androidx/core/view/MarginLayoutParamsCompat.java
+++ b/core/core/src/main/java/androidx/core/view/MarginLayoutParamsCompat.java
@@ -20,7 +20,7 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing API features in
@@ -45,7 +45,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.getMarginStart()")
     @Deprecated
-    public static int getMarginStart(@NonNull ViewGroup.MarginLayoutParams lp) {
+    public static int getMarginStart(ViewGroup.@NonNull MarginLayoutParams lp) {
         return lp.getMarginStart();
     }
 
@@ -63,7 +63,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.getMarginEnd()")
     @Deprecated
-    public static int getMarginEnd(@NonNull ViewGroup.MarginLayoutParams lp) {
+    public static int getMarginEnd(ViewGroup.@NonNull MarginLayoutParams lp) {
         return lp.getMarginEnd();
     }
 
@@ -81,7 +81,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.setMarginStart(marginStart)")
     @Deprecated
-    public static void setMarginStart(@NonNull ViewGroup.MarginLayoutParams lp, int marginStart) {
+    public static void setMarginStart(ViewGroup.@NonNull MarginLayoutParams lp, int marginStart) {
         lp.setMarginStart(marginStart);
     }
 
@@ -99,7 +99,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.setMarginEnd(marginEnd)")
     @Deprecated
-    public static void setMarginEnd(@NonNull ViewGroup.MarginLayoutParams lp, int marginEnd) {
+    public static void setMarginEnd(ViewGroup.@NonNull MarginLayoutParams lp, int marginEnd) {
         lp.setMarginEnd(marginEnd);
     }
 
@@ -111,7 +111,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.isMarginRelative()")
     @Deprecated
-    public static boolean isMarginRelative(@NonNull ViewGroup.MarginLayoutParams lp) {
+    public static boolean isMarginRelative(ViewGroup.@NonNull MarginLayoutParams lp) {
         return lp.isMarginRelative();
     }
 
@@ -123,7 +123,7 @@
      * @deprecated Use {@link ViewGroup.MarginLayoutParams#getLayoutDirection} directly.
      */
     @Deprecated
-    public static int getLayoutDirection(@NonNull ViewGroup.MarginLayoutParams lp) {
+    public static int getLayoutDirection(ViewGroup.@NonNull MarginLayoutParams lp) {
         int result;
         result = lp.getLayoutDirection();
 
@@ -147,7 +147,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.setLayoutDirection(layoutDirection)")
     @Deprecated
-    public static void setLayoutDirection(@NonNull ViewGroup.MarginLayoutParams lp,
+    public static void setLayoutDirection(ViewGroup.@NonNull MarginLayoutParams lp,
             int layoutDirection) {
         lp.setLayoutDirection(layoutDirection);
     }
@@ -160,7 +160,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "lp.resolveLayoutDirection(layoutDirection)")
     @Deprecated
-    public static void resolveLayoutDirection(@NonNull ViewGroup.MarginLayoutParams lp,
+    public static void resolveLayoutDirection(ViewGroup.@NonNull MarginLayoutParams lp,
             int layoutDirection) {
         lp.resolveLayoutDirection(layoutDirection);
     }
diff --git a/core/core/src/main/java/androidx/core/view/MenuCompat.java b/core/core/src/main/java/androidx/core/view/MenuCompat.java
index e8b7630..7f5caf9 100644
--- a/core/core/src/main/java/androidx/core/view/MenuCompat.java
+++ b/core/core/src/main/java/androidx/core/view/MenuCompat.java
@@ -20,10 +20,11 @@
 import android.view.Menu;
 import android.view.MenuItem;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.core.internal.view.SupportMenu;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link Menu}.
  */
diff --git a/core/core/src/main/java/androidx/core/view/MenuHost.java b/core/core/src/main/java/androidx/core/view/MenuHost.java
index fd4b1c3..be9839e 100644
--- a/core/core/src/main/java/androidx/core/view/MenuHost.java
+++ b/core/core/src/main/java/androidx/core/view/MenuHost.java
@@ -17,10 +17,11 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
 import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LifecycleOwner;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A class that allows you to host and
  * keep track of {@link MenuProvider}s that will supply
@@ -64,7 +65,7 @@
      */
     @SuppressLint("LambdaLast")
     void addMenuProvider(@NonNull MenuProvider provider, @NonNull LifecycleOwner owner,
-            @NonNull Lifecycle.State state);
+            Lifecycle.@NonNull State state);
 
     /**
      * Removes the given {@link MenuProvider} from this {@link MenuHost}.
diff --git a/core/core/src/main/java/androidx/core/view/MenuHostHelper.java b/core/core/src/main/java/androidx/core/view/MenuHostHelper.java
index e37bb83..702ba6c 100644
--- a/core/core/src/main/java/androidx/core/view/MenuHostHelper.java
+++ b/core/core/src/main/java/androidx/core/view/MenuHostHelper.java
@@ -21,11 +21,12 @@
 import android.view.MenuInflater;
 import android.view.MenuItem;
 
-import androidx.annotation.NonNull;
 import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LifecycleEventObserver;
 import androidx.lifecycle.LifecycleOwner;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -171,7 +172,7 @@
      */
     @SuppressLint("LambdaLast")
     public void addMenuProvider(@NonNull MenuProvider provider, @NonNull LifecycleOwner owner,
-            @NonNull Lifecycle.State state) {
+            Lifecycle.@NonNull State state) {
         Lifecycle lifecycle = owner.getLifecycle();
         LifecycleContainer lifecycleContainer = mProviderToLifecycleContainers.remove(provider);
         if (lifecycleContainer != null) {
diff --git a/core/core/src/main/java/androidx/core/view/MenuItemCompat.java b/core/core/src/main/java/androidx/core/view/MenuItemCompat.java
index 5dbad58f..be6fc792 100644
--- a/core/core/src/main/java/androidx/core/view/MenuItemCompat.java
+++ b/core/core/src/main/java/androidx/core/view/MenuItemCompat.java
@@ -26,11 +26,12 @@
 import android.view.MenuItem;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.internal.view.SupportMenuItem;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link MenuItem}.
  * <p class="note"><strong>Note:</strong> You cannot get an instance of this class. Instead,
@@ -211,8 +212,7 @@
      *
      * @see ActionProvider
      */
-    @Nullable
-    public static MenuItem setActionProvider(@NonNull MenuItem item,
+    public static @Nullable MenuItem setActionProvider(@NonNull MenuItem item,
             @Nullable ActionProvider provider) {
         if (item instanceof SupportMenuItem) {
             return ((SupportMenuItem) item).setSupportActionProvider(provider);
@@ -230,8 +230,7 @@
      * @see ActionProvider
      * @see #setActionProvider(MenuItem, ActionProvider)
      */
-    @Nullable
-    public static ActionProvider getActionProvider(@NonNull MenuItem item) {
+    public static @Nullable ActionProvider getActionProvider(@NonNull MenuItem item) {
         if (item instanceof SupportMenuItem) {
             return ((SupportMenuItem) item).getSupportActionProvider();
         }
@@ -347,9 +346,8 @@
      *
      * @return The content description.
      */
-    @Nullable
     @SuppressWarnings("RedundantCast")
-    public static CharSequence getContentDescription(@NonNull MenuItem item) {
+    public static @Nullable CharSequence getContentDescription(@NonNull MenuItem item) {
         if (item instanceof SupportMenuItem) {
             // Cast required to target SupportMenuItem method declaration.
             return ((SupportMenuItem) item).getContentDescription();
@@ -379,9 +377,8 @@
      *
      * @return The tooltip text.
      */
-    @Nullable
     @SuppressWarnings("RedundantCast")
-    public static CharSequence getTooltipText(@NonNull MenuItem item) {
+    public static @Nullable CharSequence getTooltipText(@NonNull MenuItem item) {
         if (item instanceof SupportMenuItem) {
             // Cast required to target SupportMenuItem method declaration.
             return ((SupportMenuItem) item).getTooltipText();
@@ -551,9 +548,8 @@
      * @return the tint applied to the item's icon
      * @see #setIconTintList(MenuItem, ColorStateList)
      */
-    @Nullable
     @SuppressWarnings("RedundantCast")
-    public static ColorStateList getIconTintList(@NonNull MenuItem item) {
+    public static @Nullable ColorStateList getIconTintList(@NonNull MenuItem item) {
         if (item instanceof SupportMenuItem) {
             // Cast required to target SupportMenuItem method declaration.
             return ((SupportMenuItem) item).getIconTintList();
@@ -575,7 +571,7 @@
      * @see #setIconTintList(MenuItem, ColorStateList)
      */
     @SuppressWarnings("RedundantCast")
-    public static void setIconTintMode(@NonNull MenuItem item, @Nullable PorterDuff.Mode tintMode) {
+    public static void setIconTintMode(@NonNull MenuItem item, PorterDuff.@Nullable Mode tintMode) {
         if (item instanceof SupportMenuItem) {
             // Cast required to target SupportMenuItem method declaration.
             ((SupportMenuItem) item).setIconTintMode(tintMode);
@@ -590,9 +586,8 @@
      * @return the blending mode used to apply the tint to the item's icon
      * @see #setIconTintMode(MenuItem, PorterDuff.Mode)
      */
-    @Nullable
     @SuppressWarnings("RedundantCast")
-    public static PorterDuff.Mode getIconTintMode(@NonNull MenuItem item) {
+    public static PorterDuff.@Nullable Mode getIconTintMode(@NonNull MenuItem item) {
         if (item instanceof SupportMenuItem) {
             // Cast required to target SupportMenuItem method declaration.
             return ((SupportMenuItem) item).getIconTintMode();
diff --git a/core/core/src/main/java/androidx/core/view/MenuProvider.java b/core/core/src/main/java/androidx/core/view/MenuProvider.java
index 26e5cf6..f5d13f1 100644
--- a/core/core/src/main/java/androidx/core/view/MenuProvider.java
+++ b/core/core/src/main/java/androidx/core/view/MenuProvider.java
@@ -20,7 +20,7 @@
 import android.view.MenuInflater;
 import android.view.MenuItem;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Interface for indicating that a component will be supplying
diff --git a/core/core/src/main/java/androidx/core/view/MotionEventCompat.java b/core/core/src/main/java/androidx/core/view/MotionEventCompat.java
index bad4584..7e6250a6 100644
--- a/core/core/src/main/java/androidx/core/view/MotionEventCompat.java
+++ b/core/core/src/main/java/androidx/core/view/MotionEventCompat.java
@@ -18,7 +18,7 @@
 
 import android.view.MotionEvent;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing features in {@link MotionEvent}.
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingChild.java b/core/core/src/main/java/androidx/core/view/NestedScrollingChild.java
index ba616fe..8bab39b 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingChild.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingChild.java
@@ -23,9 +23,10 @@
 import android.view.ViewConfiguration;
 import android.view.ViewParent;
 
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * This interface should be implemented by {@link android.view.View View} subclasses that wish
  * to support dispatching nested scrolling operations to a cooperating parent
@@ -153,7 +154,7 @@
      * @see #dispatchNestedPreScroll(int, int, int[], int[])
      */
     boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow);
 
     /**
      * Dispatch one step of a nested scroll in progress before this view consumes any portion of it.
@@ -174,8 +175,8 @@
      * @return true if the parent consumed some or all of the scroll delta
      * @see #dispatchNestedScroll(int, int, int, int, int[])
      */
-    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow);
+    boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow);
 
     /**
      * Dispatch a fling to a nested scrolling parent.
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingChild2.java b/core/core/src/main/java/androidx/core/view/NestedScrollingChild2.java
index 2da12a4..b6e2b54 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingChild2.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingChild2.java
@@ -21,10 +21,11 @@
 import android.view.View;
 import android.view.ViewParent;
 
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat.NestedScrollType;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * This interface should be implemented by {@link View View} subclasses that wish
  * to support dispatching nested scrolling operations to a cooperating parent
@@ -129,7 +130,7 @@
      * @see #dispatchNestedPreScroll(int, int, int[], int[], int)
      */
     boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow,
             @NestedScrollType int type);
 
     /**
@@ -152,6 +153,6 @@
      * @return true if the parent consumed some or all of the scroll delta
      * @see #dispatchNestedScroll(int, int, int, int, int[], int)
      */
-    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow, @NestedScrollType int type);
+    boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow, @NestedScrollType int type);
 }
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingChild3.java b/core/core/src/main/java/androidx/core/view/NestedScrollingChild3.java
index dfce4ee..6a972c1 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingChild3.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingChild3.java
@@ -19,8 +19,8 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This interface should be implemented by {@link View View} subclasses that wish
@@ -70,6 +70,6 @@
      * @see NestedScrollingParent3#onNestedScroll(View, int, int, int, int, int, int[])
      */
     void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
-            @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type,
-            @NonNull int[] consumed);
+            int @Nullable [] offsetInWindow, @ViewCompat.NestedScrollType int type,
+            int @NonNull [] consumed);
 }
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingChildHelper.java b/core/core/src/main/java/androidx/core/view/NestedScrollingChildHelper.java
index 652c503..9936b77 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingChildHelper.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingChildHelper.java
@@ -23,11 +23,12 @@
 import android.view.View;
 import android.view.ViewParent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat.NestedScrollType;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper class for implementing nested scrolling child views compatible with Android platform
  * versions earlier than Android 5.0 Lollipop (API 21).
@@ -201,7 +202,7 @@
      * @return <code>true</code> if the parent consumed any of the nested scroll distance
      */
     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow) {
         return dispatchNestedScrollInternal(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow, TYPE_TOUCH, null);
     }
@@ -215,7 +216,7 @@
      * @return <code>true</code> if the parent consumed any of the nested scroll distance
      */
     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type) {
+            int dyUnconsumed, int @Nullable [] offsetInWindow, @NestedScrollType int type) {
         return dispatchNestedScrollInternal(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow, type, null);
     }
@@ -227,15 +228,15 @@
      * method with the same signature to implement the standard policy.
      */
     public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type,
-            @Nullable int[] consumed) {
+            int dyUnconsumed, int @Nullable [] offsetInWindow, @NestedScrollType int type,
+            int @Nullable [] consumed) {
         dispatchNestedScrollInternal(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow, type, consumed);
     }
 
     private boolean dispatchNestedScrollInternal(int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
-            @NestedScrollType int type, @Nullable int[] consumed) {
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow,
+            @NestedScrollType int type, int @Nullable [] consumed) {
         if (isNestedScrollingEnabled()) {
             final ViewParent parent = getNestedScrollingParentForType(type);
             if (parent == null) {
@@ -284,8 +285,8 @@
      *
      * @return true if the parent consumed any of the nested scroll
      */
-    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow) {
+    public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow) {
         return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH);
     }
 
@@ -298,8 +299,8 @@
      *
      * @return true if the parent consumed any of the nested scroll
      */
-    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
+    public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow, @NestedScrollType int type) {
         if (isNestedScrollingEnabled()) {
             final ViewParent parent = getNestedScrollingParentForType(type);
             if (parent == null) {
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingParent.java b/core/core/src/main/java/androidx/core/view/NestedScrollingParent.java
index b6bd3ca..c2ebf9b 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingParent.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingParent.java
@@ -22,9 +22,10 @@
 import android.view.View;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.NonNull;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * This interface should be implemented by {@link android.view.ViewGroup ViewGroup} subclasses
  * that wish to support scrolling operations delegated by a nested child view.
@@ -138,7 +139,7 @@
      * @param dy Vertical scroll distance in pixels
      * @param consumed Output. The horizontal and vertical scroll distance consumed by this parent
      */
-    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
+    void onNestedPreScroll(@NonNull View target, int dx, int dy, int @NonNull [] consumed);
 
     /**
      * Request a fling from a nested scroll.
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingParent2.java b/core/core/src/main/java/androidx/core/view/NestedScrollingParent2.java
index d888700..f29e98f 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingParent2.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingParent2.java
@@ -20,10 +20,11 @@
 import android.view.MotionEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.core.view.ViewCompat.NestedScrollType;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * This interface should be implemented by {@link android.view.ViewGroup ViewGroup} subclasses
  * that wish to support scrolling operations delegated by a nested child view.
@@ -144,7 +145,7 @@
      * @param consumed Output. The horizontal and vertical scroll distance consumed by this parent
      * @param type the type of input which cause this scroll event
      */
-    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
+    void onNestedPreScroll(@NonNull View target, int dx, int dy, int @NonNull [] consumed,
             @NestedScrollType int type);
 
 }
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingParent3.java b/core/core/src/main/java/androidx/core/view/NestedScrollingParent3.java
index dca8537..726ac19 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingParent3.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingParent3.java
@@ -19,7 +19,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This interface should be implemented by {@link android.view.ViewGroup ViewGroup} subclasses
@@ -75,6 +75,6 @@
      * @see NestedScrollingChild3#dispatchNestedScroll(int, int, int, int, int[], int, int[])
      */
     void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
+            int dyUnconsumed, @ViewCompat.NestedScrollType int type, int @NonNull [] consumed);
 
 }
diff --git a/core/core/src/main/java/androidx/core/view/NestedScrollingParentHelper.java b/core/core/src/main/java/androidx/core/view/NestedScrollingParentHelper.java
index 766fe8c..e803e44 100644
--- a/core/core/src/main/java/androidx/core/view/NestedScrollingParentHelper.java
+++ b/core/core/src/main/java/androidx/core/view/NestedScrollingParentHelper.java
@@ -20,10 +20,11 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
 import androidx.core.view.ViewCompat.NestedScrollType;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper class for implementing nested scrolling parent views compatible with Android platform
  * versions earlier than Android 5.0 Lollipop (API 21).
diff --git a/core/core/src/main/java/androidx/core/view/OnApplyWindowInsetsListener.java b/core/core/src/main/java/androidx/core/view/OnApplyWindowInsetsListener.java
index 157dad3..dcc569b3 100644
--- a/core/core/src/main/java/androidx/core/view/OnApplyWindowInsetsListener.java
+++ b/core/core/src/main/java/androidx/core/view/OnApplyWindowInsetsListener.java
@@ -18,7 +18,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Listener for applying window insets on a view in a custom way.
@@ -41,6 +41,6 @@
      * @param insets The insets to apply
      * @return The insets supplied, minus any insets that were consumed
      */
-    @NonNull
-    WindowInsetsCompat onApplyWindowInsets(@NonNull View v, @NonNull WindowInsetsCompat insets);
+    @NonNull WindowInsetsCompat onApplyWindowInsets(@NonNull View v,
+            @NonNull WindowInsetsCompat insets);
 }
\ No newline at end of file
diff --git a/core/core/src/main/java/androidx/core/view/OnReceiveContentListener.java b/core/core/src/main/java/androidx/core/view/OnReceiveContentListener.java
index 4682dce..69e2a4fb 100644
--- a/core/core/src/main/java/androidx/core/view/OnReceiveContentListener.java
+++ b/core/core/src/main/java/androidx/core/view/OnReceiveContentListener.java
@@ -18,8 +18,8 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Listener for apps to implement handling for insertion of content. Content may be both text and
@@ -121,6 +121,6 @@
      * succeed even if this method returns null. For example, an app may end up not inserting
      * an item if it exceeds the app's size limit for that type of content.
      */
-    @Nullable
-    ContentInfoCompat onReceiveContent(@NonNull View view, @NonNull ContentInfoCompat payload);
+    @Nullable ContentInfoCompat onReceiveContent(@NonNull View view,
+            @NonNull ContentInfoCompat payload);
 }
diff --git a/core/core/src/main/java/androidx/core/view/OnReceiveContentViewBehavior.java b/core/core/src/main/java/androidx/core/view/OnReceiveContentViewBehavior.java
index 16156c8..430015c 100644
--- a/core/core/src/main/java/androidx/core/view/OnReceiveContentViewBehavior.java
+++ b/core/core/src/main/java/androidx/core/view/OnReceiveContentViewBehavior.java
@@ -16,8 +16,8 @@
 
 package androidx.core.view;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface for widgets to implement default behavior for receiving content. Content may be both
@@ -40,6 +40,5 @@
      * @return The portion of the passed-in content that was not handled (may be all, some, or none
      * of the passed-in content).
      */
-    @Nullable
-    ContentInfoCompat onReceiveContent(@NonNull ContentInfoCompat payload);
+    @Nullable ContentInfoCompat onReceiveContent(@NonNull ContentInfoCompat payload);
 }
diff --git a/core/core/src/main/java/androidx/core/view/OneShotPreDrawListener.java b/core/core/src/main/java/androidx/core/view/OneShotPreDrawListener.java
index af1ce27..2973e6b 100644
--- a/core/core/src/main/java/androidx/core/view/OneShotPreDrawListener.java
+++ b/core/core/src/main/java/androidx/core/view/OneShotPreDrawListener.java
@@ -19,7 +19,7 @@
 import android.view.View;
 import android.view.ViewTreeObserver;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * An OnPreDrawListener that will remove itself after one OnPreDraw call. Typical
@@ -53,9 +53,9 @@
      * @return The added OneShotPreDrawListener. It can be removed prior to
      * the onPreDraw by calling {@link #removeListener()}.
      */
-    @NonNull
     @SuppressWarnings("ConstantConditions") // Validating nullability contracts.
-    public static OneShotPreDrawListener add(@NonNull View view, @NonNull Runnable runnable) {
+    public static @NonNull OneShotPreDrawListener add(@NonNull View view,
+            @NonNull Runnable runnable) {
         if (view == null) throw new NullPointerException("view == null");
         if (runnable == null) throw new NullPointerException("runnable == null");
 
diff --git a/core/core/src/main/java/androidx/core/view/PointerIconCompat.java b/core/core/src/main/java/androidx/core/view/PointerIconCompat.java
index b07a397..db4cc26 100644
--- a/core/core/src/main/java/androidx/core/view/PointerIconCompat.java
+++ b/core/core/src/main/java/androidx/core/view/PointerIconCompat.java
@@ -25,11 +25,12 @@
 import android.graphics.Bitmap;
 import android.view.PointerIcon;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link PointerIcon} in a backwards compatible
  * fashion.
@@ -112,9 +113,8 @@
 
     /**
      */
-    @Nullable
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    public Object getPointerIcon() {
+    public @Nullable Object getPointerIcon() {
         return mPointerIcon;
     }
 
@@ -126,8 +126,7 @@
      * @param style The pointer icon style.
      * @return The pointer icon.
      */
-    @NonNull
-    public static PointerIconCompat getSystemIcon(@NonNull Context context, int style) {
+    public static @NonNull PointerIconCompat getSystemIcon(@NonNull Context context, int style) {
         if (SDK_INT >= 24) {
             return new PointerIconCompat(Api24Impl.getSystemIcon(context, style));
         } else {
@@ -148,8 +147,8 @@
      * @throws IllegalArgumentException if bitmap is null, or if the x/y hotspot
      *         parameters are invalid.
      */
-    @NonNull
-    public static PointerIconCompat create(@NonNull Bitmap bitmap, float hotSpotX, float hotSpotY) {
+    public static @NonNull PointerIconCompat create(@NonNull Bitmap bitmap, float hotSpotX,
+            float hotSpotY) {
         if (SDK_INT >= 24) {
             return new PointerIconCompat(Api24Impl.create(bitmap, hotSpotX, hotSpotY));
         } else {
@@ -177,8 +176,7 @@
      * @throws Resources.NotFoundException if the resource was not found or the drawable
      * linked in the resource was not found.
      */
-    @NonNull
-    public static PointerIconCompat load(@NonNull Resources resources, int resourceId) {
+    public static @NonNull PointerIconCompat load(@NonNull Resources resources, int resourceId) {
         if (SDK_INT >= 24) {
             return new PointerIconCompat(Api24Impl.load(resources, resourceId));
         } else {
diff --git a/core/core/src/main/java/androidx/core/view/ScaleGestureDetectorCompat.java b/core/core/src/main/java/androidx/core/view/ScaleGestureDetectorCompat.java
index 02cc957..929df36 100644
--- a/core/core/src/main/java/androidx/core/view/ScaleGestureDetectorCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ScaleGestureDetectorCompat.java
@@ -18,7 +18,7 @@
 
 import android.view.ScaleGestureDetector;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing features in {@link ScaleGestureDetector}.
diff --git a/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
index 07be72b..bb4912e 100644
--- a/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
@@ -23,9 +23,10 @@
 import android.view.View;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /** Compat to access {@link ScrollFeedbackProvider} across different build versions. */
 public class ScrollFeedbackProviderCompat {
 
@@ -40,8 +41,7 @@
     }
 
     /** Creates an instance of {@link ScrollFeedbackProviderCompat}. */
-    @NonNull
-    public static ScrollFeedbackProviderCompat createProvider(@NonNull View view) {
+    public static @NonNull ScrollFeedbackProviderCompat createProvider(@NonNull View view) {
         return new ScrollFeedbackProviderCompat(view);
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/SoftwareKeyboardControllerCompat.java b/core/core/src/main/java/androidx/core/view/SoftwareKeyboardControllerCompat.java
index a490031..b0d9fc4 100644
--- a/core/core/src/main/java/androidx/core/view/SoftwareKeyboardControllerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/SoftwareKeyboardControllerCompat.java
@@ -24,10 +24,11 @@
 import android.view.WindowInsetsController;
 import android.view.inputmethod.InputMethodManager;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -96,8 +97,7 @@
     @RequiresApi(20)
     private static class Impl20 extends Impl {
 
-        @Nullable
-        private final View mView;
+        private final @Nullable View mView;
 
         Impl20(@Nullable View view) {
             mView = view;
@@ -152,11 +152,9 @@
     @RequiresApi(30)
     private static class Impl30 extends Impl20 {
 
-        @Nullable
-        private View mView;
+        private @Nullable View mView;
 
-        @Nullable
-        private WindowInsetsController mWindowInsetsController;
+        private @Nullable WindowInsetsController mWindowInsetsController;
 
         Impl30(@NonNull View view) {
             super(view);
diff --git a/core/core/src/main/java/androidx/core/view/TintableBackgroundView.java b/core/core/src/main/java/androidx/core/view/TintableBackgroundView.java
index 338f46e..6be159c 100644
--- a/core/core/src/main/java/androidx/core/view/TintableBackgroundView.java
+++ b/core/core/src/main/java/androidx/core/view/TintableBackgroundView.java
@@ -19,7 +19,7 @@
 import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface which allows a {@link android.view.View} to receive background tinting calls from
@@ -50,8 +50,7 @@
      *
      * @return the tint applied to the background drawable
      */
-    @Nullable
-    ColorStateList getSupportBackgroundTintList();
+    @Nullable ColorStateList getSupportBackgroundTintList();
 
     /**
      * Specifies the blending mode used to apply the tint specified by
@@ -62,7 +61,7 @@
      *                 {@code null} to clear tint
      * @see #getSupportBackgroundTintMode()
      */
-    void setSupportBackgroundTintMode(@Nullable PorterDuff.Mode tintMode);
+    void setSupportBackgroundTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Return the blending mode used to apply the tint to the background
@@ -71,6 +70,5 @@
      * @return the blending mode used to apply the tint to the background
      *         drawable
      */
-    @Nullable
-    PorterDuff.Mode getSupportBackgroundTintMode();
+    PorterDuff.@Nullable Mode getSupportBackgroundTintMode();
 }
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
index e5b812f..445be25 100644
--- a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
@@ -26,11 +26,12 @@
 import android.view.VelocityTracker;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.util.Collections;
 import java.util.Map;
@@ -296,8 +297,8 @@
         sFallbackTrackers.remove(tracker);
     }
 
-    @Nullable
-    private static VelocityTrackerFallback getFallbackTrackerOrNull(VelocityTracker tracker) {
+    private static @Nullable VelocityTrackerFallback getFallbackTrackerOrNull(
+            VelocityTracker tracker) {
         return sFallbackTrackers.get(tracker);
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
index 3743472..669ed97 100644
--- a/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
@@ -20,7 +20,7 @@
 
 import android.view.MotionEvent;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * A fallback implementation of {@link android.view.VelocityTracker}. The methods its provide
diff --git a/core/core/src/main/java/androidx/core/view/ViewCompat.java b/core/core/src/main/java/androidx/core/view/ViewCompat.java
index d0f4a1e..80d0cd2 100644
--- a/core/core/src/main/java/androidx/core/view/ViewCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewCompat.java
@@ -65,8 +65,6 @@
 import androidx.annotation.FloatRange;
 import androidx.annotation.IdRes;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.Px;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
@@ -85,6 +83,9 @@
 import androidx.core.view.contentcapture.ContentCaptureSessionCompat;
 import androidx.core.viewtree.ViewTree;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.ref.WeakReference;
@@ -577,7 +578,7 @@
      * @param defStyleRes Default style resource passed into the view constructor.
      */
     public static void saveAttributeDataForStyleable(@NonNull View view,
-            @SuppressLint("ContextFirst") @NonNull Context context, @NonNull int[] styleable,
+            @SuppressLint("ContextFirst") @NonNull Context context, int @NonNull [] styleable,
             @Nullable AttributeSet attrs, @NonNull TypedArray t, int defStyleAttr,
             int defStyleRes) {
         if (Build.VERSION.SDK_INT >= 29) {
@@ -833,7 +834,7 @@
      * @param autofillHints The autofill hints to set. If the array is emtpy, {@code null} is set.
      * {@link android.R.attr#autofillHints}
      */
-    public static void setAutofillHints(@NonNull View view, @Nullable String... autofillHints) {
+    public static void setAutofillHints(@NonNull View view, String @Nullable ... autofillHints) {
         if (Build.VERSION.SDK_INT >= 26) {
             Api26Impl.setAutofillHints(view, autofillHints);
         }
@@ -993,8 +994,7 @@
      * @param v The View against which to invoke the method.
      * @return The View's autofill id.
      */
-    @Nullable
-    public static AutofillIdCompat getAutofillId(@NonNull View v) {
+    public static @Nullable AutofillIdCompat getAutofillId(@NonNull View v) {
         if (Build.VERSION.SDK_INT >= 26) {
             return AutofillIdCompat.toAutofillIdCompat(Api26Impl.getAutofillId(v));
         }
@@ -1158,8 +1158,7 @@
      * inherited by ancestors, default session or {@code null} if content capture is disabled for
      * this view.
      */
-    @Nullable
-    public static ContentCaptureSessionCompat getContentCaptureSession(@NonNull View v) {
+    public static @Nullable ContentCaptureSessionCompat getContentCaptureSession(@NonNull View v) {
         if (Build.VERSION.SDK_INT >= 29) {
             ContentCaptureSession session = Api29Impl.getContentCaptureSession(v);
             if (session == null) {
@@ -1230,8 +1229,8 @@
      *         still get an object that is being used to provide backward compatibility. Returns
      *         {@code null} if there is no delegate attached.
      */
-    @Nullable
-    public static AccessibilityDelegateCompat getAccessibilityDelegate(@NonNull View view) {
+    public static @Nullable AccessibilityDelegateCompat getAccessibilityDelegate(
+            @NonNull View view) {
         final View.AccessibilityDelegate delegate = getAccessibilityDelegateInternal(view);
         if (delegate == null) {
             return null;
@@ -1250,8 +1249,8 @@
         setAccessibilityDelegate(v, delegateCompat);
     }
 
-    @Nullable
-    private static View.AccessibilityDelegate getAccessibilityDelegateInternal(@NonNull View v) {
+    private static View.@Nullable AccessibilityDelegate getAccessibilityDelegateInternal(
+            @NonNull View v) {
         if (Build.VERSION.SDK_INT >= 29) {
             return Api29Impl.getAccessibilityDelegate(v);
         } else {
@@ -1260,8 +1259,7 @@
     }
 
     @SuppressWarnings("JavaReflectionMemberAccess") // Private field
-    @Nullable
-    private static View.AccessibilityDelegate getAccessibilityDelegateThroughReflection(
+    private static View.@Nullable AccessibilityDelegate getAccessibilityDelegateThroughReflection(
             @NonNull View v) {
         if (sAccessibilityDelegateCheckFailed) {
             return null; // View implementation might have changed.
@@ -1668,8 +1666,8 @@
      *     <li>API &lt; 21: No-op</li>
      * </ul>
      */
-    public static void replaceAccessibilityAction(@NonNull View view, @NonNull
-            AccessibilityActionCompat replacedAction, @Nullable CharSequence label,
+    public static void replaceAccessibilityAction(@NonNull View view,
+            @NonNull AccessibilityActionCompat replacedAction, @Nullable CharSequence label,
             @Nullable AccessibilityViewCommand command) {
         if (command == null && label == null) {
             ViewCompat.removeAccessibilityAction(view, replacedAction.getId());
@@ -1769,8 +1767,7 @@
      * @see #setStateDescription(View, CharSequence)
      */
     @UiThread
-    @Nullable
-    public static CharSequence getStateDescription(@NonNull View view) {
+    public static @Nullable CharSequence getStateDescription(@NonNull View view) {
         return stateDescriptionProperty().get(view);
     }
 
@@ -1826,8 +1823,8 @@
      *
      * @see AccessibilityNodeProviderCompat
      */
-    @Nullable
-    public static AccessibilityNodeProviderCompat getAccessibilityNodeProvider(@NonNull View view) {
+    public static @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(
+            @NonNull View view) {
         AccessibilityNodeProvider provider = view.getAccessibilityNodeProvider();
         if (provider != null) {
             return new AccessibilityNodeProviderCompat(provider);
@@ -2036,8 +2033,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "view.getParentForAccessibility()")
     @Deprecated
-    @Nullable
-    public static ViewParent getParentForAccessibility(@NonNull View view) {
+    public static @Nullable ViewParent getParentForAccessibility(@NonNull View view) {
         return view.getParentForAccessibility();
     }
 
@@ -2057,8 +2053,7 @@
      * @see View#findViewById(int)
      */
     @SuppressWarnings("TypeParameterUnusedInFormals")
-    @NonNull
-    public static <T extends View> T requireViewById(@NonNull View view, @IdRes int id) {
+    public static <T extends View> @NonNull T requireViewById(@NonNull View view, @IdRes int id) {
         if (Build.VERSION.SDK_INT >= 28) {
             return ViewCompat.Api28Impl.requireViewById(view, id);
         }
@@ -2395,8 +2390,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "view.getMatrix()")
     @Deprecated
-    @Nullable
-    public static Matrix getMatrix(View view) {
+    public static @Nullable Matrix getMatrix(View view) {
         return view.getMatrix();
     }
 
@@ -2440,8 +2434,7 @@
      * @deprecated Call {@link View#animate()} directly.
      */
     @Deprecated
-    @NonNull
-    public static ViewPropertyAnimatorCompat animate(@NonNull View view) {
+    public static @NonNull ViewPropertyAnimatorCompat animate(@NonNull View view) {
         if (sViewPropertyAnimatorMap == null) {
             sViewPropertyAnimatorMap = new WeakHashMap<>();
         }
@@ -2829,8 +2822,7 @@
      * @return The name used of the View to be used to identify Views in Transitions or null
      * if no name has been given.
      */
-    @Nullable
-    public static String getTransitionName(@NonNull View view) {
+    public static @Nullable String getTransitionName(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getTransitionName(view);
         }
@@ -2972,7 +2964,7 @@
      * @param view view on which to the listener.
      * @param listener listener for the applied window insets.
      */
-    public static void setOnApplyWindowInsetsListener(@NonNull final View view,
+    public static void setOnApplyWindowInsetsListener(final @NonNull View view,
             final @Nullable OnApplyWindowInsetsListener listener) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.setOnApplyWindowInsetsListener(view, listener);
@@ -2991,8 +2983,7 @@
      * @param insets Insets to apply
      * @return The supplied insets with any applied insets consumed
      */
-    @NonNull
-    public static WindowInsetsCompat onApplyWindowInsets(@NonNull View view,
+    public static @NonNull WindowInsetsCompat onApplyWindowInsets(@NonNull View view,
             @NonNull WindowInsetsCompat insets) {
         if (Build.VERSION.SDK_INT >= 21) {
             final WindowInsets unwrapped = insets.toWindowInsets();
@@ -3020,8 +3011,7 @@
      * @param insets Insets to apply
      * @return The provided insets minus the insets that were consumed
      */
-    @NonNull
-    public static WindowInsetsCompat dispatchApplyWindowInsets(@NonNull View view,
+    public static @NonNull WindowInsetsCompat dispatchApplyWindowInsets(@NonNull View view,
             @NonNull WindowInsetsCompat insets) {
         if (Build.VERSION.SDK_INT >= 21) {
             final WindowInsets unwrapped = insets.toWindowInsets();
@@ -3067,8 +3057,7 @@
      *
      * @see View#getSystemGestureExclusionRects
      */
-    @NonNull
-    public static List<Rect> getSystemGestureExclusionRects(@NonNull View view) {
+    public static @NonNull List<Rect> getSystemGestureExclusionRects(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 29) {
             return Api29Impl.getSystemGestureExclusionRects(view);
         }
@@ -3083,8 +3072,7 @@
      *
      * @return WindowInsetsCompat from the top of the view hierarchy or null if View is detached
      */
-    @Nullable
-    public static WindowInsetsCompat getRootWindowInsets(@NonNull View view) {
+    public static @Nullable WindowInsetsCompat getRootWindowInsets(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getRootWindowInsets(view);
         } else if (Build.VERSION.SDK_INT >= 21) {
@@ -3105,8 +3093,7 @@
      *                       by this view
      * @return Insets that should be passed along to views under this one
      */
-    @NonNull
-    public static WindowInsetsCompat computeSystemWindowInsets(@NonNull View view,
+    public static @NonNull WindowInsetsCompat computeSystemWindowInsets(@NonNull View view,
             @NonNull WindowInsetsCompat insets, @NonNull Rect outLocalInsets) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.computeSystemWindowInsets(view, insets, outLocalInsets);
@@ -3123,9 +3110,9 @@
      * @deprecated Prefer {@link WindowCompat#getInsetsController(Window, View)} to explicitly
      * specify the window (such as when the view is in a dialog).
      */
-    @Nullable
     @Deprecated
-    public static WindowInsetsControllerCompat getWindowInsetsController(@NonNull View view) {
+    public static @Nullable WindowInsetsControllerCompat getWindowInsetsController(
+            @NonNull View view) {
         if (Build.VERSION.SDK_INT >= 30) {
             return Api30Impl.getWindowInsetsController(view);
         } else {
@@ -3164,7 +3151,7 @@
      *                 callback
      */
     public static void setWindowInsetsAnimationCallback(@NonNull View view,
-            @Nullable final WindowInsetsAnimationCompat.Callback callback) {
+            final WindowInsetsAnimationCompat.@Nullable Callback callback) {
         WindowInsetsAnimationCompat.setCallback(view, callback);
     }
 
@@ -3203,8 +3190,8 @@
      *                  not be null or empty if a non-null listener is passed in.
      * @param listener The listener to use. This can be null to reset to the default behavior.
      */
-    public static void setOnReceiveContentListener(@NonNull View view, @Nullable String[] mimeTypes,
-            @Nullable OnReceiveContentListener listener) {
+    public static void setOnReceiveContentListener(@NonNull View view,
+            String @Nullable [] mimeTypes, @Nullable OnReceiveContentListener listener) {
         if (Build.VERSION.SDK_INT >= 31) {
             Api31Impl.setOnReceiveContentListener(view, mimeTypes, listener);
             return;
@@ -3252,8 +3239,7 @@
      * @return The MIME types accepted by the {@link OnReceiveContentListener} for the given view
      * (may include patterns such as "image/*").
      */
-    @Nullable
-    public static String[] getOnReceiveContentMimeTypes(@NonNull View view) {
+    public static String @Nullable [] getOnReceiveContentMimeTypes(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 31) {
             return Api31Impl.getReceiveContentMimeTypes(view);
         }
@@ -3277,8 +3263,7 @@
      * @return The portion of the passed-in content that was not handled (may be all, some, or none
      * of the passed-in content).
      */
-    @Nullable
-    public static ContentInfoCompat performReceiveContent(@NonNull View view,
+    public static @Nullable ContentInfoCompat performReceiveContent(@NonNull View view,
             @NonNull ContentInfoCompat payload) {
         if (Log.isLoggable(TAG, Log.DEBUG)) {
             Log.d(TAG, "performReceiveContent: " + payload
@@ -3311,7 +3296,7 @@
         private Api31Impl() {}
 
         public static void setOnReceiveContentListener(@NonNull View view,
-                @Nullable String[] mimeTypes, @Nullable final OnReceiveContentListener listener) {
+                String @Nullable [] mimeTypes, final @Nullable OnReceiveContentListener listener) {
             if (listener == null) {
                 view.setOnReceiveContentListener(mimeTypes, null);
             } else {
@@ -3320,13 +3305,11 @@
             }
         }
 
-        @Nullable
-        public static String[] getReceiveContentMimeTypes(@NonNull View view) {
+        public static String @Nullable [] getReceiveContentMimeTypes(@NonNull View view) {
             return view.getReceiveContentMimeTypes();
         }
 
-        @Nullable
-        public static ContentInfoCompat performReceiveContent(@NonNull View view,
+        public static @Nullable ContentInfoCompat performReceiveContent(@NonNull View view,
                 @NonNull ContentInfoCompat payload) {
             ContentInfo platPayload = payload.toContentInfo();
             ContentInfo platResult = view.performReceiveContent(platPayload);
@@ -3345,16 +3328,15 @@
     private static final class OnReceiveContentListenerAdapter implements
             android.view.OnReceiveContentListener {
 
-        @NonNull
-        private final OnReceiveContentListener mJetpackListener;
+        private final @NonNull OnReceiveContentListener mJetpackListener;
 
         OnReceiveContentListenerAdapter(@NonNull OnReceiveContentListener jetpackListener) {
             mJetpackListener = jetpackListener;
         }
 
-        @Nullable
         @Override
-        public ContentInfo onReceiveContent(@NonNull View view, @NonNull ContentInfo platPayload) {
+        public @Nullable ContentInfo onReceiveContent(@NonNull View view,
+                @NonNull ContentInfo platPayload) {
             ContentInfoCompat payload = ContentInfoCompat.toContentInfoCompat(platPayload);
             ContentInfoCompat result = mJetpackListener.onReceiveContent(view, payload);
             if (result == null) {
@@ -3459,8 +3441,7 @@
      * Only returns meaningful info when running on API v21 or newer, or if {@code view}
      * implements the {@code TintableBackgroundView} interface.
      */
-    @Nullable
-    public static ColorStateList getBackgroundTintList(@NonNull View view) {
+    public static @Nullable ColorStateList getBackgroundTintList(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getBackgroundTintList(view);
         }
@@ -3506,8 +3487,7 @@
      * Only returns meaningful info when running on API v21 or newer, or if {@code view}
      * implements the {@code TintableBackgroundView} interface.
      */
-    @Nullable
-    public static PorterDuff.Mode getBackgroundTintMode(@NonNull View view) {
+    public static PorterDuff.@Nullable Mode getBackgroundTintMode(@NonNull View view) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getBackgroundTintMode(view);
         }
@@ -3525,7 +3505,7 @@
      * previous to API v21, it will only take effect if {@code view} implement the
      * {@code TintableBackgroundView} interface.
      */
-    public static void setBackgroundTintMode(@NonNull View view, @Nullable PorterDuff.Mode mode) {
+    public static void setBackgroundTintMode(@NonNull View view, PorterDuff.@Nullable Mode mode) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.setBackgroundTintMode(view, mode);
 
@@ -3679,7 +3659,7 @@
      */
     @SuppressWarnings("RedundantCast") // Intentionally invoking interface method.
     public static boolean dispatchNestedScroll(@NonNull View view, int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.dispatchNestedScroll(view, dxConsumed, dyConsumed, dxUnconsumed,
                     dyUnconsumed, offsetInWindow);
@@ -3711,7 +3691,7 @@
      */
     @SuppressWarnings("RedundantCast") // Intentionally invoking interface method.
     public static boolean dispatchNestedPreScroll(@NonNull View view, int dx, int dy,
-            @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
+            int @Nullable [] consumed, int @Nullable [] offsetInWindow) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.dispatchNestedPreScroll(view, dx, dy, consumed, offsetInWindow);
         }
@@ -3840,8 +3820,8 @@
      *                 component of dx and <code>consumed[1]</code> the consumed dy.
      */
     public static void dispatchNestedScroll(@NonNull View view, int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
-            @NestedScrollType int type, @NonNull int[] consumed) {
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow,
+            @NestedScrollType int type, int @NonNull [] consumed) {
         if (view instanceof NestedScrollingChild3) {
             ((NestedScrollingChild3) view).dispatchNestedScroll(dxConsumed, dyConsumed,
                     dxUnconsumed, dyUnconsumed, offsetInWindow, type, consumed);
@@ -3877,7 +3857,7 @@
      * @see #dispatchNestedPreScroll(View, int, int, int[], int[])
      */
     public static boolean dispatchNestedScroll(@NonNull View view, int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
+            int dxUnconsumed, int dyUnconsumed, int @Nullable [] offsetInWindow,
             @NestedScrollType int type) {
         if (view instanceof NestedScrollingChild2) {
             return ((NestedScrollingChild2) view).dispatchNestedScroll(dxConsumed, dyConsumed,
@@ -3911,7 +3891,8 @@
      * @see #dispatchNestedScroll(View, int, int, int, int, int[])
      */
     public static boolean dispatchNestedPreScroll(@NonNull View view, int dx, int dy,
-            @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type) {
+            int @Nullable [] consumed, int @Nullable [] offsetInWindow,
+            @NestedScrollType int type) {
         if (view instanceof NestedScrollingChild2) {
             return ((NestedScrollingChild2) view).dispatchNestedPreScroll(dx, dy, consumed,
                     offsetInWindow, type);
@@ -4211,8 +4192,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "view.getClipBounds()")
     @Deprecated
-    @Nullable
-    public static Rect getClipBounds(@NonNull View view) {
+    public static @Nullable Rect getClipBounds(@NonNull View view) {
         return view.getClipBounds();
     }
 
@@ -4338,8 +4318,7 @@
      */
     @androidx.annotation.ReplaceWith(expression = "view.getDisplay()")
     @Deprecated
-    @Nullable
-    public static Display getDisplay(@NonNull View view) {
+    public static @Nullable Display getDisplay(@NonNull View view) {
         return view.getDisplay();
     }
 
@@ -4363,7 +4342,7 @@
      */
     @SuppressWarnings("deprecation")
     public static boolean startDragAndDrop(@NonNull View v, @Nullable ClipData data,
-            @NonNull View.DragShadowBuilder shadowBuilder, @Nullable Object myLocalState,
+            View.@NonNull DragShadowBuilder shadowBuilder, @Nullable Object myLocalState,
             int flags) {
         if (Build.VERSION.SDK_INT >= 24) {
             return Api24Impl.startDragAndDrop(v, data, shadowBuilder, myLocalState, flags);
@@ -4385,7 +4364,7 @@
      * Update the drag shadow while drag and drop is in progress.
      */
     public static void updateDragShadow(@NonNull View v,
-            @NonNull View.DragShadowBuilder shadowBuilder) {
+            View.@NonNull DragShadowBuilder shadowBuilder) {
         if (Build.VERSION.SDK_INT >= 24) {
             Api24Impl.updateDragShadow(v, shadowBuilder);
         }
@@ -4494,8 +4473,7 @@
      * @return the nearest keyboard navigation cluster in the specified direction, or {@code null}
      *         if one can't be found or if API < 26.
      */
-    @Nullable
-    public static View keyboardNavigationClusterSearch(@NonNull View view,
+    public static @Nullable View keyboardNavigationClusterSearch(@NonNull View view,
             @Nullable View currentCluster, @FocusDirection int direction) {
         if (Build.VERSION.SDK_INT >= 26) {
             return Api26Impl.keyboardNavigationClusterSearch(view, currentCluster, direction);
@@ -4784,9 +4762,8 @@
      *
      * {@see #setAccessibilityPaneTitle}.
      */
-    @Nullable
     @UiThread
-    public static CharSequence getAccessibilityPaneTitle(@NonNull View view) {
+    public static @Nullable CharSequence getAccessibilityPaneTitle(@NonNull View view) {
         return paneTitleProperty().get(view);
     }
 
@@ -5080,8 +5057,7 @@
 
         // This is a cache (per keypress) of all the views which either have listeners or
         // contain a view with listeners. This is only accessed on the UI thread.
-        @Nullable
-        private WeakHashMap<View, Boolean> mViewsContainingListeners = null;
+        private @Nullable WeakHashMap<View, Boolean> mViewsContainingListeners = null;
 
         // Keeps track of which Views have unhandled key focus for which keys. This doesn't
         // include modifiers.
@@ -5128,8 +5104,7 @@
             return consumer != null;
         }
 
-        @Nullable
-        private View dispatchInOrder(View view, KeyEvent event) {
+        private @Nullable View dispatchInOrder(View view, KeyEvent event) {
             if (mViewsContainingListeners == null || !mViewsContainingListeners.containsKey(view)) {
                 return null;
             }
@@ -5264,8 +5239,7 @@
         }
 
         // Only called on SDK 21 and 22
-        @Nullable
-        public static WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
+        public static @Nullable WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
             return WindowInsetsCompat.Api21ReflectionHolder.getRootWindowInsets(v);
         }
 
@@ -5465,8 +5439,7 @@
             // This class is not instantiable.
         }
 
-        @Nullable
-        public static WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
+        public static @Nullable WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
             final WindowInsets wi = v.getRootWindowInsets();
             if (wi == null) return null;
 
@@ -5498,7 +5471,7 @@
         }
 
         static void saveAttributeDataForStyleable(@NonNull View view,
-                @NonNull Context context, @NonNull int[] styleable, @Nullable AttributeSet attrs,
+                @NonNull Context context, int @NonNull [] styleable, @Nullable AttributeSet attrs,
                 @NonNull TypedArray t, int defStyleAttr, int defStyleRes) {
             view.saveAttributeDataForStyleable(
                     context, styleable, attrs, t, defStyleAttr, defStyleRes);
@@ -5533,8 +5506,8 @@
             // This class is not instantiable.
         }
 
-        @Nullable
-        public static WindowInsetsControllerCompat getWindowInsetsController(@NonNull View view) {
+        public static @Nullable WindowInsetsControllerCompat getWindowInsetsController(
+                @NonNull View view) {
             WindowInsetsController windowInsetsController = view.getWindowInsetsController();
             return windowInsetsController != null
                     ? WindowInsetsControllerCompat.toWindowInsetsControllerCompat(
@@ -5650,7 +5623,7 @@
         }
 
         static boolean startDragAndDrop(@NonNull View view, @Nullable ClipData data,
-                @NonNull View.DragShadowBuilder shadowBuilder, @Nullable Object myLocalState,
+                View.@NonNull DragShadowBuilder shadowBuilder, @Nullable Object myLocalState,
                 int flags) {
             return view.startDragAndDrop(data, shadowBuilder, myLocalState, flags);
         }
@@ -5660,7 +5633,7 @@
         }
 
         static void updateDragShadow(@NonNull View view,
-                @NonNull View.DragShadowBuilder shadowBuilder) {
+                View.@NonNull DragShadowBuilder shadowBuilder) {
             view.updateDragShadow(shadowBuilder);
         }
 
diff --git a/core/core/src/main/java/androidx/core/view/ViewConfigurationCompat.java b/core/core/src/main/java/androidx/core/view/ViewConfigurationCompat.java
index b4e425d..f65b6e9 100644
--- a/core/core/src/main/java/androidx/core/view/ViewConfigurationCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewConfigurationCompat.java
@@ -27,10 +27,11 @@
 import android.view.VelocityTracker;
 import android.view.ViewConfiguration;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Supplier;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Method;
 
 /**
diff --git a/core/core/src/main/java/androidx/core/view/ViewGroupCompat.java b/core/core/src/main/java/androidx/core/view/ViewGroupCompat.java
index 285036e..7080da9 100644
--- a/core/core/src/main/java/androidx/core/view/ViewGroupCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewGroupCompat.java
@@ -22,11 +22,12 @@
 import android.view.WindowInsets;
 import android.view.accessibility.AccessibilityEvent;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.core.R;
 import androidx.core.view.ViewCompat.ScrollAxis;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link ViewGroup}.
  */
diff --git a/core/core/src/main/java/androidx/core/view/ViewParentCompat.java b/core/core/src/main/java/androidx/core/view/ViewParentCompat.java
index c10dc12..86ce9ea 100644
--- a/core/core/src/main/java/androidx/core/view/ViewParentCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewParentCompat.java
@@ -25,9 +25,10 @@
 import android.view.ViewParent;
 import android.view.accessibility.AccessibilityEvent;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link ViewParent}.
  */
@@ -182,7 +183,7 @@
      * @param consumed Output. The horizontal and vertical scroll distance consumed by this parent
      */
     public static void onNestedPreScroll(@NonNull ViewParent parent, @NonNull View target, int dx,
-            int dy, @NonNull int[] consumed) {
+            int dy, int @NonNull [] consumed) {
         onNestedPreScroll(parent, target, dx, dy, consumed, ViewCompat.TYPE_TOUCH);
     }
 
@@ -337,7 +338,7 @@
      */
     public static void onNestedScroll(@NonNull ViewParent parent, @NonNull View target,
             int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type,
-            @NonNull int[] consumed) {
+            int @NonNull [] consumed) {
 
         if (parent instanceof NestedScrollingParent3) {
             ((NestedScrollingParent3) parent).onNestedScroll(target, dxConsumed, dyConsumed,
@@ -394,7 +395,7 @@
      */
     @SuppressWarnings("RedundantCast") // Intentionally invoking interface method.
     public static void onNestedPreScroll(@NonNull ViewParent parent, @NonNull View target, int dx,
-            int dy, @NonNull int[] consumed, int type) {
+            int dy, int @NonNull [] consumed, int type) {
         if (parent instanceof NestedScrollingParent2) {
             // First try the NestedScrollingParent2 API
             ((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, type);
diff --git a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorCompat.java b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorCompat.java
index 18d981b..0da3b51 100644
--- a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorCompat.java
@@ -25,10 +25,11 @@
 import android.view.ViewPropertyAnimator;
 import android.view.animation.Interpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 
 public final class ViewPropertyAnimatorCompat {
@@ -47,8 +48,7 @@
      * cannot be negative.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat setDuration(long value) {
+    public @NonNull ViewPropertyAnimatorCompat setDuration(long value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().setDuration(value);
@@ -63,8 +63,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat alpha(float value) {
+    public @NonNull ViewPropertyAnimatorCompat alpha(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().alpha(value);
@@ -79,8 +78,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat alphaBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat alphaBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().alphaBy(value);
@@ -95,8 +93,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationX(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationX(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().translationX(value);
@@ -111,8 +108,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationY(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationY(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().translationY(value);
@@ -145,8 +141,7 @@
      * @param runnable The action to run when the next animation ends.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat withEndAction(@NonNull Runnable runnable) {
+    public @NonNull ViewPropertyAnimatorCompat withEndAction(@NonNull Runnable runnable) {
         View view;
         if ((view = mView.get()) != null) {
             ViewPropertyAnimator animator = view.animate();
@@ -180,8 +175,7 @@
      * @param value The TimeInterpolator to be used for ensuing property animations.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat setInterpolator(@Nullable Interpolator value) {
+    public @NonNull ViewPropertyAnimatorCompat setInterpolator(@Nullable Interpolator value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().setInterpolator(value);
@@ -194,8 +188,7 @@
      *
      * @return The timing interpolator for this animation.
      */
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         View view;
         if ((view = mView.get()) != null) {
             ViewPropertyAnimator animator = view.animate();
@@ -213,8 +206,7 @@
      * cannot be negative.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat setStartDelay(long value) {
+    public @NonNull ViewPropertyAnimatorCompat setStartDelay(long value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().setStartDelay(value);
@@ -246,8 +238,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotation(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotation(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotation(value);
@@ -262,8 +253,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotationBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotationBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotationBy(value);
@@ -278,8 +268,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotationX(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotationX(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotationX(value);
@@ -294,8 +283,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotationXBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotationXBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotationXBy(value);
@@ -310,8 +298,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotationY(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotationY(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotationY(value);
@@ -326,8 +313,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat rotationYBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat rotationYBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().rotationYBy(value);
@@ -342,8 +328,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat scaleX(float value) {
+    public @NonNull ViewPropertyAnimatorCompat scaleX(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().scaleX(value);
@@ -358,8 +343,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat scaleXBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat scaleXBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().scaleXBy(value);
@@ -374,8 +358,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat scaleY(float value) {
+    public @NonNull ViewPropertyAnimatorCompat scaleY(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().scaleY(value);
@@ -390,8 +373,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat scaleYBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat scaleYBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().scaleYBy(value);
@@ -416,8 +398,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat x(float value) {
+    public @NonNull ViewPropertyAnimatorCompat x(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().x(value);
@@ -432,8 +413,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat xBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat xBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().xBy(value);
@@ -448,8 +428,7 @@
      * @param value The value to be animated to.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat y(float value) {
+    public @NonNull ViewPropertyAnimatorCompat y(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().y(value);
@@ -464,8 +443,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat yBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat yBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().yBy(value);
@@ -480,8 +458,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationXBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationXBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().translationXBy(value);
@@ -496,8 +473,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationYBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationYBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             view.animate().translationYBy(value);
@@ -514,8 +490,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationZBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationZBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             if (Build.VERSION.SDK_INT >= 21) {
@@ -535,8 +510,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat translationZ(float value) {
+    public @NonNull ViewPropertyAnimatorCompat translationZ(float value) {
         View view;
         if ((view = mView.get()) != null) {
             if (Build.VERSION.SDK_INT >= 21) {
@@ -556,8 +530,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat z(float value) {
+    public @NonNull ViewPropertyAnimatorCompat z(float value) {
         View view;
         if ((view = mView.get()) != null) {
             if (Build.VERSION.SDK_INT >= 21) {
@@ -577,8 +550,7 @@
      * @param value The amount to be animated by, as an offset from the current value.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat zBy(float value) {
+    public @NonNull ViewPropertyAnimatorCompat zBy(float value) {
         View view;
         if ((view = mView.get()) != null) {
             if (Build.VERSION.SDK_INT >= 21) {
@@ -625,9 +597,8 @@
      * @see View#setLayerType(int, Paint)
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
     @SuppressLint("WrongConstant")
-    public ViewPropertyAnimatorCompat withLayer() {
+    public @NonNull ViewPropertyAnimatorCompat withLayer() {
         View view;
         if ((view = mView.get()) != null) {
             ViewPropertyAnimator animator = view.animate();
@@ -647,8 +618,7 @@
      * @param runnable The action to run when the next animation starts.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat withStartAction(@NonNull Runnable runnable) {
+    public @NonNull ViewPropertyAnimatorCompat withStartAction(@NonNull Runnable runnable) {
         View view;
         if ((view = mView.get()) != null) {
             ViewPropertyAnimator animator = view.animate();
@@ -665,8 +635,7 @@
      * <code>null</code> removes any existing listener.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat setListener(
+    public @NonNull ViewPropertyAnimatorCompat setListener(
             final @Nullable ViewPropertyAnimatorListener listener) {
         final View view;
         if ((view = mView.get()) != null) {
@@ -706,8 +675,7 @@
      * <code>null</code> removes any existing listener.
      * @return This object, allowing calls to methods in this class to be chained.
      */
-    @NonNull
-    public ViewPropertyAnimatorCompat setUpdateListener(
+    public @NonNull ViewPropertyAnimatorCompat setUpdateListener(
             final @Nullable ViewPropertyAnimatorUpdateListener listener) {
         final View view;
         if ((view = mView.get()) != null) {
diff --git a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListener.java b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListener.java
index be25230..f1b9bc7 100644
--- a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListener.java
+++ b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListener.java
@@ -18,7 +18,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * <p>An animation listener receives notifications from an animation.
diff --git a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListenerAdapter.java b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListenerAdapter.java
index 04806fb..c602883 100644
--- a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListenerAdapter.java
+++ b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorListenerAdapter.java
@@ -18,7 +18,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This adapter class provides empty implementations of the methods from
diff --git a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorUpdateListener.java b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorUpdateListener.java
index 663c46b..293dfc9 100644
--- a/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorUpdateListener.java
+++ b/core/core/src/main/java/androidx/core/view/ViewPropertyAnimatorUpdateListener.java
@@ -18,7 +18,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Implementors of this interface can add themselves as update listeners
diff --git a/core/core/src/main/java/androidx/core/view/ViewStructureCompat.java b/core/core/src/main/java/androidx/core/view/ViewStructureCompat.java
index 6e1f28e..def380c 100644
--- a/core/core/src/main/java/androidx/core/view/ViewStructureCompat.java
+++ b/core/core/src/main/java/androidx/core/view/ViewStructureCompat.java
@@ -20,9 +20,10 @@
 
 import android.view.ViewStructure;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link ViewStructure}.
  * <p>
@@ -44,8 +45,7 @@
      * @return wrapped class
      */
     @RequiresApi(23)
-    @NonNull
-    public static ViewStructureCompat toViewStructureCompat(
+    public static @NonNull ViewStructureCompat toViewStructureCompat(
             @NonNull ViewStructure contentCaptureSession) {
         return new ViewStructureCompat(contentCaptureSession);
     }
@@ -60,8 +60,7 @@
      * @see ViewStructureCompat#toViewStructureCompat(ViewStructure)
      */
     @RequiresApi(23)
-    @NonNull
-    public ViewStructure toViewStructure() {
+    public @NonNull ViewStructure toViewStructure() {
         return (ViewStructure) mWrappedObj;
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/WindowCompat.java b/core/core/src/main/java/androidx/core/view/WindowCompat.java
index 3c16649..4084f63 100644
--- a/core/core/src/main/java/androidx/core/view/WindowCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowCompat.java
@@ -23,9 +23,10 @@
 import android.view.Window;
 
 import androidx.annotation.IdRes;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link Window}.
  */
@@ -84,8 +85,8 @@
      * @see Window#findViewById(int)
      */
     @SuppressWarnings("TypeParameterUnusedInFormals")
-    @NonNull
-    public static <T extends View> T requireViewById(@NonNull Window window, @IdRes int id) {
+    public static <T extends View> @NonNull T requireViewById(@NonNull Window window,
+            @IdRes int id) {
         if (Build.VERSION.SDK_INT >= 28) {
             return Api28Impl.requireViewById(window, id);
         }
@@ -131,8 +132,7 @@
      * @return The {@link WindowInsetsControllerCompat} for the window.
      * @see Window#getInsetsController()
      */
-    @NonNull
-    public static WindowInsetsControllerCompat getInsetsController(@NonNull Window window,
+    public static @NonNull WindowInsetsControllerCompat getInsetsController(@NonNull Window window,
             @NonNull View view) {
         return new WindowInsetsControllerCompat(window, view);
     }
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
index cb1d92c..b800b8f 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
@@ -34,8 +34,6 @@
 
 import androidx.annotation.FloatRange;
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.R;
@@ -44,6 +42,9 @@
 import androidx.core.view.WindowInsetsCompat.Type.InsetsType;
 import androidx.interpolator.view.animation.FastOutLinearInInterpolator;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -158,8 +159,7 @@
      *
      * @return The interpolator used for this animation.
      */
-    @Nullable
-    public Interpolator getInterpolator() {
+    public @Nullable Interpolator getInterpolator() {
         return mImpl.getInterpolator();
     }
 
@@ -226,7 +226,7 @@
         }
 
         @RequiresApi(30)
-        private BoundsCompat(@NonNull WindowInsetsAnimation.Bounds bounds) {
+        private BoundsCompat(WindowInsetsAnimation.@NonNull Bounds bounds) {
             mLowerBound = Impl30.getLowerBounds(bounds);
             mUpperBound = Impl30.getHigherBounds(bounds);
         }
@@ -250,8 +250,7 @@
          * @see #getUpperBound()
          * @see WindowInsetsAnimationControllerCompat#getHiddenStateInsets
          */
-        @NonNull
-        public Insets getLowerBound() {
+        public @NonNull Insets getLowerBound() {
             return mLowerBound;
         }
 
@@ -274,8 +273,7 @@
          * @see #getLowerBound()
          * @see WindowInsetsAnimationControllerCompat#getShownStateInsets
          */
-        @NonNull
-        public Insets getUpperBound() {
+        public @NonNull Insets getUpperBound() {
             return mUpperBound;
         }
 
@@ -290,8 +288,7 @@
          * @see WindowInsetsCompat#inset
          * @see WindowInsetsAnimationCompat.Callback#onStart
          */
-        @NonNull
-        public BoundsCompat inset(@NonNull Insets insets) {
+        public @NonNull BoundsCompat inset(@NonNull Insets insets) {
             return new BoundsCompat(
                     // TODO: refactor so that WindowInsets.insetInsets() is in a more appropriate
                     //  place eventually.
@@ -310,8 +307,7 @@
          * Creates a new instance of {@link WindowInsetsAnimation.Bounds} from this compat instance.
          */
         @RequiresApi(30)
-        @NonNull
-        public WindowInsetsAnimation.Bounds toBounds() {
+        public WindowInsetsAnimation.@NonNull Bounds toBounds() {
             return Impl30.createPlatformBounds(this);
         }
 
@@ -320,8 +316,8 @@
          * platform {@link android.view.WindowInsetsAnimation.Bounds}.
          */
         @RequiresApi(30)
-        @NonNull
-        public static BoundsCompat toBoundsCompat(@NonNull WindowInsetsAnimation.Bounds bounds) {
+        public static @NonNull BoundsCompat toBoundsCompat(
+                WindowInsetsAnimation.@NonNull Bounds bounds) {
             return new BoundsCompat(bounds);
         }
     }
@@ -483,8 +479,7 @@
          * dispatched to
          * the subtree of the hierarchy.
          */
-        @NonNull
-        public BoundsCompat onStart(
+        public @NonNull BoundsCompat onStart(
                 @NonNull WindowInsetsAnimationCompat animation,
                 @NonNull BoundsCompat bounds) {
             return bounds;
@@ -508,8 +503,7 @@
          * @param runningAnimations The currently running animations.
          * @return The insets to dispatch to the subtree of the hierarchy.
          */
-        @NonNull
-        public abstract WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets,
+        public abstract @NonNull WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets,
                 @NonNull List<WindowInsetsAnimationCompat> runningAnimations);
 
         /**
@@ -535,8 +529,7 @@
         @InsetsType
         private final int mTypeMask;
         private float mFraction;
-        @Nullable
-        private final Interpolator mInterpolator;
+        private final @Nullable Interpolator mInterpolator;
         private final long mDurationMillis;
         private float mAlpha = 1f;
 
@@ -561,8 +554,7 @@
             return mFraction;
         }
 
-        @Nullable
-        public Interpolator getInterpolator() {
+        public @Nullable Interpolator getInterpolator() {
             return mInterpolator;
         }
 
@@ -627,8 +619,8 @@
             super(typeMask, interpolator, durationMillis);
         }
 
-        static void setCallback(@NonNull final View view,
-                @Nullable final Callback callback) {
+        static void setCallback(final @NonNull View view,
+                final @Nullable Callback callback) {
             final View.OnApplyWindowInsetsListener proxyListener = callback != null
                     ? createProxyListener(view, callback)
                     : null;
@@ -645,14 +637,12 @@
             }
         }
 
-        @NonNull
-        private static View.OnApplyWindowInsetsListener createProxyListener(
-                @NonNull View view, @NonNull final Callback callback) {
+        private static View.@NonNull OnApplyWindowInsetsListener createProxyListener(
+                @NonNull View view, final @NonNull Callback callback) {
             return new Impl21OnApplyWindowInsetsListener(view, callback);
         }
 
-        @NonNull
-        static BoundsCompat computeAnimationBounds(
+        static @NonNull BoundsCompat computeAnimationBounds(
                 @NonNull WindowInsetsCompat targetInsets,
                 @NonNull WindowInsetsCompat startingInsets, int mask) {
             Insets targetInsetsInsets = targetInsets.getInsets(mask);
@@ -705,8 +695,7 @@
          * This allows for a smoother animation especially in the common case of showing and hiding
          * the IME.
          */
-        @Nullable
-        static Interpolator createInsetInterpolator(int showingTypes, int hidingTypes) {
+        static @Nullable Interpolator createInsetInterpolator(int showingTypes, int hidingTypes) {
             if ((showingTypes & WindowInsetsCompat.Type.ime()) != 0) {
                 return SHOW_IME_INTERPOLATOR;
             } else if ((hidingTypes & WindowInsetsCompat.Type.ime()) != 0) {
@@ -771,9 +760,9 @@
                         : null;
             }
 
-            @NonNull
             @Override
-            public WindowInsets onApplyWindowInsets(final View v, @NonNull WindowInsets insets) {
+            public @NonNull WindowInsets onApplyWindowInsets(final View v,
+                    @NonNull WindowInsets insets) {
                 // We cannot rely on the compat insets value until the view is laid out.
                 if (!v.isLaidOut()) {
                     mLastInsets = toWindowInsetsCompat(insets, v);
@@ -889,8 +878,8 @@
          * Forward the call to view.onApplyWindowInsets if there is no other listener attached to
          * the view.
          */
-        @NonNull
-        static WindowInsets forwardToViewIfNeeded(@NonNull View v, @NonNull WindowInsets insets) {
+        static @NonNull WindowInsets forwardToViewIfNeeded(@NonNull View v,
+                @NonNull WindowInsets insets) {
             // If the app set an on apply window listener, it will be called after this
             // and will decide whether to call the view's onApplyWindowInsets.
             if (v.getTag(R.id.tag_on_apply_window_listener) != null) {
@@ -978,8 +967,7 @@
             }
         }
 
-        @Nullable
-        static Callback getCallback(View child) {
+        static @Nullable Callback getCallback(View child) {
             Object listener = child.getTag(
                     R.id.tag_window_insets_animation_callback);
             Callback callback = null;
@@ -993,8 +981,7 @@
     @RequiresApi(30)
     private static class Impl30 extends Impl {
 
-        @NonNull
-        private final WindowInsetsAnimation mWrapped;
+        private final @NonNull WindowInsetsAnimation mWrapped;
 
         Impl30(@NonNull WindowInsetsAnimation wrapped) {
             super(0, null, 0);
@@ -1011,8 +998,7 @@
         }
 
         @Override
-        @Nullable
-        public Interpolator getInterpolator() {
+        public @Nullable Interpolator getInterpolator() {
             return mWrapped.getInterpolator();
         }
 
@@ -1051,7 +1037,7 @@
 
             private final Callback mCompat;
 
-            ProxyCallback(@NonNull final WindowInsetsAnimationCompat.Callback compat) {
+            ProxyCallback(final WindowInsetsAnimationCompat.@NonNull Callback compat) {
                 super(compat.getDispatchMode());
                 mCompat = compat;
             }
@@ -1061,8 +1047,7 @@
             private final HashMap<WindowInsetsAnimation, WindowInsetsAnimationCompat>
                     mAnimations = new HashMap<>();
 
-            @NonNull
-            private WindowInsetsAnimationCompat getWindowInsetsAnimationCompat(
+            private @NonNull WindowInsetsAnimationCompat getWindowInsetsAnimationCompat(
                     @NonNull WindowInsetsAnimation animation) {
                 WindowInsetsAnimationCompat animationCompat = mAnimations.get(
                         animation);
@@ -1078,19 +1063,17 @@
                 mCompat.onPrepare(getWindowInsetsAnimationCompat(animation));
             }
 
-            @NonNull
             @Override
-            public WindowInsetsAnimation.Bounds onStart(
+            public WindowInsetsAnimation.@NonNull Bounds onStart(
                     @NonNull WindowInsetsAnimation animation,
-                    @NonNull WindowInsetsAnimation.Bounds bounds) {
+                    WindowInsetsAnimation.@NonNull Bounds bounds) {
                 return mCompat.onStart(
                         getWindowInsetsAnimationCompat(animation),
                         BoundsCompat.toBoundsCompat(bounds)).toBounds();
             }
 
-            @NonNull
             @Override
-            public WindowInsets onProgress(@NonNull WindowInsets insets,
+            public @NonNull WindowInsets onProgress(@NonNull WindowInsets insets,
                     @NonNull List<WindowInsetsAnimation> runningAnimations) {
                 if (mTmpRunningAnimations == null) {
                     mTmpRunningAnimations = new ArrayList<>(runningAnimations.size());
@@ -1124,20 +1107,18 @@
             view.setWindowInsetsAnimationCallback(platformCallback);
         }
 
-        @NonNull
-        public static WindowInsetsAnimation.Bounds createPlatformBounds(
+        public static WindowInsetsAnimation.@NonNull Bounds createPlatformBounds(
                 @NonNull BoundsCompat bounds) {
             return new WindowInsetsAnimation.Bounds(bounds.getLowerBound().toPlatformInsets(),
                     bounds.getUpperBound().toPlatformInsets());
         }
 
-        @NonNull
-        public static Insets getLowerBounds(@NonNull WindowInsetsAnimation.Bounds bounds) {
+        public static @NonNull Insets getLowerBounds(WindowInsetsAnimation.@NonNull Bounds bounds) {
             return Insets.toCompatInsets(bounds.getLowerBound());
         }
 
-        @NonNull
-        public static Insets getHigherBounds(@NonNull WindowInsetsAnimation.Bounds bounds) {
+        public static @NonNull Insets getHigherBounds(
+                WindowInsetsAnimation.@NonNull Bounds bounds) {
             return Insets.toCompatInsets(bounds.getUpperBound());
         }
     }
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControlListenerCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControlListenerCompat.java
index cc27431..528168d 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControlListenerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControlListenerCompat.java
@@ -18,11 +18,12 @@
 
 import android.view.inputmethod.EditorInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.WindowInsetsCompat.Type;
 import androidx.core.view.WindowInsetsCompat.Type.InsetsType;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Listener that encapsulates a request to
  * {@link WindowInsetsControllerCompat#controlWindowInsetsAnimation}.
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControllerCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControllerCompat.java
index 964a06f..f809808 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControllerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationControllerCompat.java
@@ -21,13 +21,14 @@
 import android.view.WindowInsetsAnimationController;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.graphics.Insets;
 import androidx.core.view.WindowInsetsCompat.Type;
 import androidx.core.view.WindowInsetsCompat.Type.InsetsType;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Controller for app-driven animation of system windows.
  * <p>
@@ -61,8 +62,7 @@
      * @return Insets when the windows this animation is controlling are fully hidden.
      * @see WindowInsetsAnimationCompat.BoundsCompat#getLowerBound()
      */
-    @NonNull
-    public Insets getHiddenStateInsets() {
+    public @NonNull Insets getHiddenStateInsets() {
         return mImpl.getHiddenStateInsets();
     }
 
@@ -80,8 +80,7 @@
      * @return Insets when the windows this animation is controlling are fully shown.
      * @see WindowInsetsAnimationCompat.BoundsCompat#getUpperBound()
      */
-    @NonNull
-    public Insets getShownStateInsets() {
+    public @NonNull Insets getShownStateInsets() {
         return mImpl.getShownStateInsets();
     }
 
@@ -95,8 +94,7 @@
      * @return The current insets on the currently showing frame. These insets will change as the
      * animation progresses to reflect the current insets provided by the controlled window.
      */
-    @NonNull
-    public Insets getCurrentInsets() {
+    public @NonNull Insets getCurrentInsets() {
         return mImpl.getCurrentInsets();
     }
 
@@ -230,18 +228,15 @@
             //privatex
         }
 
-        @NonNull
-        public Insets getHiddenStateInsets() {
+        public @NonNull Insets getHiddenStateInsets() {
             return Insets.NONE;
         }
 
-        @NonNull
-        public Insets getShownStateInsets() {
+        public @NonNull Insets getShownStateInsets() {
             return Insets.NONE;
         }
 
-        @NonNull
-        public Insets getCurrentInsets() {
+        public @NonNull Insets getCurrentInsets() {
             return Insets.NONE;
         }
 
@@ -285,21 +280,18 @@
             mController = controller;
         }
 
-        @NonNull
         @Override
-        public Insets getHiddenStateInsets() {
+        public @NonNull Insets getHiddenStateInsets() {
             return Insets.toCompatInsets(mController.getHiddenStateInsets());
         }
 
-        @NonNull
         @Override
-        public Insets getShownStateInsets() {
+        public @NonNull Insets getShownStateInsets() {
             return Insets.toCompatInsets(mController.getShownStateInsets());
         }
 
-        @NonNull
         @Override
-        public Insets getCurrentInsets() {
+        public @NonNull Insets getCurrentInsets() {
             return Insets.toCompatInsets(mController.getCurrentInsets());
         }
 
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsCompat.java
index 819e4f2..ab3f0b1 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsCompat.java
@@ -31,8 +31,6 @@
 
 import androidx.annotation.IntDef;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.graphics.Insets;
@@ -40,6 +38,9 @@
 import androidx.core.util.Preconditions;
 import androidx.core.view.WindowInsetsCompat.Type.InsetsType;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.reflect.Constructor;
@@ -71,8 +72,7 @@
      *
      * @see #isConsumed()
      */
-    @NonNull
-    public static final WindowInsetsCompat CONSUMED;
+    public static final @NonNull WindowInsetsCompat CONSUMED;
 
     static {
         if (SDK_INT >= 34) {
@@ -110,7 +110,7 @@
      *
      * @param src source from which values are copied
      */
-    public WindowInsetsCompat(@Nullable final WindowInsetsCompat src) {
+    public WindowInsetsCompat(final @Nullable WindowInsetsCompat src) {
         if (src != null) {
             // We'll copy over from the 'src' instance's impl
             final Impl srcImpl = src.mImpl;
@@ -148,9 +148,8 @@
      * @param insets source insets to wrap
      * @return the wrapped instance
      */
-    @NonNull
     @RequiresApi(20)
-    public static WindowInsetsCompat toWindowInsetsCompat(@NonNull WindowInsets insets) {
+    public static @NonNull WindowInsetsCompat toWindowInsetsCompat(@NonNull WindowInsets insets) {
         return toWindowInsetsCompat(insets, null);
     }
 
@@ -167,9 +166,8 @@
      *             view needs be attached to the window, otherwise it will be ignored.
      * @return the wrapped instance
      */
-    @NonNull
     @RequiresApi(20)
-    public static WindowInsetsCompat toWindowInsetsCompat(@NonNull WindowInsets insets,
+    public static @NonNull WindowInsetsCompat toWindowInsetsCompat(@NonNull WindowInsets insets,
             @Nullable View view) {
         WindowInsetsCompat wic = new WindowInsetsCompat(Preconditions.checkNotNull(insets));
         if (view != null && view.isAttachedToWindow()) {
@@ -321,8 +319,7 @@
      * {@link #CONSUMED} instead to stop dispatching insets.
      */
     @Deprecated
-    @NonNull
-    public WindowInsetsCompat consumeSystemWindowInsets() {
+    public @NonNull WindowInsetsCompat consumeSystemWindowInsets() {
         return mImpl.consumeSystemWindowInsets();
     }
 
@@ -342,8 +339,8 @@
      */
     @SuppressWarnings("deprecation") // Builder.setSystemWindowInsets
     @Deprecated
-    @NonNull
-    public WindowInsetsCompat replaceSystemWindowInsets(int left, int top, int right, int bottom) {
+    public @NonNull WindowInsetsCompat replaceSystemWindowInsets(int left, int top, int right,
+            int bottom) {
         return new Builder(this)
                 .setSystemWindowInsets(Insets.of(left, top, right, bottom))
                 .build();
@@ -363,8 +360,7 @@
      */
     @SuppressWarnings("deprecation")
     @Deprecated
-    @NonNull
-    public WindowInsetsCompat replaceSystemWindowInsets(@NonNull Rect systemWindowInsets) {
+    public @NonNull WindowInsetsCompat replaceSystemWindowInsets(@NonNull Rect systemWindowInsets) {
         return new Builder(this)
                 .setSystemWindowInsets(Insets.of(systemWindowInsets))
                 .build();
@@ -480,8 +476,7 @@
      * {@link #CONSUMED} instead to stop dispatching insets.
      */
     @Deprecated
-    @NonNull
-    public WindowInsetsCompat consumeStableInsets() {
+    public @NonNull WindowInsetsCompat consumeStableInsets() {
         return mImpl.consumeStableInsets();
     }
 
@@ -493,8 +488,7 @@
      * @return the display cutout or null if there is none
      * @see DisplayCutoutCompat
      */
-    @Nullable
-    public DisplayCutoutCompat getDisplayCutout() {
+    public @Nullable DisplayCutoutCompat getDisplayCutout() {
         return mImpl.getDisplayCutout();
     }
 
@@ -509,8 +503,7 @@
      * {@link #CONSUMED} instead to stop dispatching insets.
      */
     @Deprecated
-    @NonNull
-    public WindowInsetsCompat consumeDisplayCutout() {
+    public @NonNull WindowInsetsCompat consumeDisplayCutout() {
         return mImpl.consumeDisplayCutout();
     }
 
@@ -529,8 +522,7 @@
      * @deprecated Use {@link #getInsets(int)} with {@link Type#systemBars()} instead.
      */
     @Deprecated
-    @NonNull
-    public Insets getSystemWindowInsets() {
+    public @NonNull Insets getSystemWindowInsets() {
         return mImpl.getSystemWindowInsets();
     }
 
@@ -552,8 +544,7 @@
      * instead.
      */
     @Deprecated
-    @NonNull
-    public Insets getStableInsets() {
+    public @NonNull Insets getStableInsets() {
         return mImpl.getStableInsets();
     }
 
@@ -569,8 +560,7 @@
      * instead.
      */
     @Deprecated
-    @NonNull
-    public Insets getMandatorySystemGestureInsets() {
+    public @NonNull Insets getMandatorySystemGestureInsets() {
         return mImpl.getMandatorySystemGestureInsets();
     }
 
@@ -588,8 +578,7 @@
      * instead.
      */
     @Deprecated
-    @NonNull
-    public Insets getTappableElementInsets() {
+    public @NonNull Insets getTappableElementInsets() {
         return mImpl.getTappableElementInsets();
     }
 
@@ -609,8 +598,7 @@
      * instead.
      */
     @Deprecated
-    @NonNull
-    public Insets getSystemGestureInsets() {
+    public @NonNull Insets getSystemGestureInsets() {
         return mImpl.getSystemGestureInsets();
     }
 
@@ -629,8 +617,7 @@
      *
      * @see #inset(int, int, int, int)
      */
-    @NonNull
-    public WindowInsetsCompat inset(@NonNull Insets insets) {
+    public @NonNull WindowInsetsCompat inset(@NonNull Insets insets) {
         return inset(insets.left, insets.top, insets.right, insets.bottom);
     }
 
@@ -653,9 +640,9 @@
      *
      * @return the inset insets
      */
-    @NonNull
-    public WindowInsetsCompat inset(@IntRange(from = 0) int left, @IntRange(from = 0) int top,
-            @IntRange(from = 0) int right, @IntRange(from = 0) int bottom) {
+    public @NonNull WindowInsetsCompat inset(@IntRange(from = 0) int left,
+            @IntRange(from = 0) int top, @IntRange(from = 0) int right,
+            @IntRange(from = 0) int bottom) {
         return mImpl.inset(left, top, right, bottom);
     }
 
@@ -671,8 +658,7 @@
      * @param typeMask Bit mask of {@link Type}s to query the insets for.
      * @return The insets.
      */
-    @NonNull
-    public Insets getInsets(@InsetsType int typeMask) {
+    public @NonNull Insets getInsets(@InsetsType int typeMask) {
         return mImpl.getInsets(typeMask);
     }
 
@@ -699,8 +685,7 @@
      *                                  IME is dynamic depending on the {@link EditorInfo} of the
      *                                  currently focused view, as well as the UI state of the IME.
      */
-    @NonNull
-    public Insets getInsetsIgnoringVisibility(@InsetsType int typeMask) {
+    public @NonNull Insets getInsetsIgnoringVisibility(@InsetsType int typeMask) {
         return mImpl.getInsetsIgnoringVisibility(typeMask);
     }
 
@@ -743,16 +728,14 @@
      *
      * @return the wrapped WindowInsets instance
      */
-    @Nullable
     @RequiresApi(20)
-    public WindowInsets toWindowInsets() {
+    public @Nullable WindowInsets toWindowInsets() {
         return mImpl instanceof Impl20 ? ((Impl20) mImpl).mPlatformInsets : null;
     }
 
     private static class Impl {
         @SuppressWarnings("deprecation")
-        @NonNull
-        static final WindowInsetsCompat CONSUMED = new WindowInsetsCompat.Builder()
+        static final @NonNull WindowInsetsCompat CONSUMED = new WindowInsetsCompat.Builder()
                 .build()
                 .consumeDisplayCutout()
                 .consumeStableInsets()
@@ -772,66 +755,54 @@
             return false;
         }
 
-        @NonNull
-        WindowInsetsCompat consumeSystemWindowInsets() {
+        @NonNull WindowInsetsCompat consumeSystemWindowInsets() {
             return mHost;
         }
 
-        @NonNull
-        WindowInsetsCompat consumeStableInsets() {
+        @NonNull WindowInsetsCompat consumeStableInsets() {
             return mHost;
         }
 
-        @Nullable
-        DisplayCutoutCompat getDisplayCutout() {
+        @Nullable DisplayCutoutCompat getDisplayCutout() {
             return null;
         }
 
-        @NonNull
-        WindowInsetsCompat consumeDisplayCutout() {
+        @NonNull WindowInsetsCompat consumeDisplayCutout() {
             return mHost;
         }
 
-        @NonNull
-        Insets getSystemWindowInsets() {
+        @NonNull Insets getSystemWindowInsets() {
             return Insets.NONE;
         }
 
-        @NonNull
-        Insets getStableInsets() {
+        @NonNull Insets getStableInsets() {
             return Insets.NONE;
         }
 
-        @NonNull
-        Insets getSystemGestureInsets() {
+        @NonNull Insets getSystemGestureInsets() {
             // Pre-Q return the system window insets
             return getSystemWindowInsets();
         }
 
-        @NonNull
-        Insets getMandatorySystemGestureInsets() {
+        @NonNull Insets getMandatorySystemGestureInsets() {
             // Pre-Q return the system window insets
             return getSystemWindowInsets();
         }
 
-        @NonNull
-        Insets getTappableElementInsets() {
+        @NonNull Insets getTappableElementInsets() {
             // Pre-Q return the system window insets
             return getSystemWindowInsets();
         }
 
-        @NonNull
-        WindowInsetsCompat inset(int left, int top, int right, int bottom) {
+        @NonNull WindowInsetsCompat inset(int left, int top, int right, int bottom) {
             return CONSUMED;
         }
 
-        @NonNull
-        Insets getInsets(@InsetsType int typeMask) {
+        @NonNull Insets getInsets(@InsetsType int typeMask) {
             return Insets.NONE;
         }
 
-        @NonNull
-        Insets getInsetsIgnoringVisibility(@InsetsType int typeMask) {
+        @NonNull Insets getInsetsIgnoringVisibility(@InsetsType int typeMask) {
             if ((typeMask & Type.IME) != 0) {
                 throw new IllegalArgumentException("Unable to query the maximum insets for IME");
             }
@@ -895,8 +866,7 @@
         private static Field sVisibleInsetsField;
         private static Field sAttachInfoField;
 
-        @NonNull
-        final WindowInsets mPlatformInsets;
+        final @NonNull WindowInsets mPlatformInsets;
 
         // TODO(175859616) save all insets in the array
         private Insets[] mOverriddenInsets;
@@ -923,15 +893,13 @@
             return mPlatformInsets.isRound();
         }
 
-        @NonNull
         @Override
-        public Insets getInsets(int typeMask) {
+        public @NonNull Insets getInsets(int typeMask) {
             return getInsets(typeMask, false);
         }
 
-        @NonNull
         @Override
-        public Insets getInsetsIgnoringVisibility(int typeMask) {
+        public @NonNull Insets getInsetsIgnoringVisibility(int typeMask) {
             return getInsets(typeMask, true);
         }
 
@@ -950,8 +918,7 @@
         }
 
         @SuppressLint("WrongConstant")
-        @NonNull
-        private Insets getInsets(final int typeMask, final boolean ignoreVisibility) {
+        private @NonNull Insets getInsets(final int typeMask, final boolean ignoreVisibility) {
             Insets result = Insets.NONE;
             for (int i = Type.FIRST; i <= Type.LAST; i = i << 1) {
                 if ((typeMask & i) == 0) {
@@ -963,8 +930,7 @@
         }
 
         @SuppressWarnings("deprecation")
-        @NonNull
-        protected Insets getInsetsForType(@InsetsType int type, boolean ignoreVisibility) {
+        protected @NonNull Insets getInsetsForType(@InsetsType int type, boolean ignoreVisibility) {
             switch (type) {
                 case Type.STATUS_BARS: {
                     if (ignoreVisibility) {
@@ -1078,8 +1044,7 @@
         }
 
         @Override
-        @NonNull
-        final Insets getSystemWindowInsets() {
+        final @NonNull Insets getSystemWindowInsets() {
             if (mSystemWindowInsets == null) {
                 mSystemWindowInsets = Insets.of(
                         mPlatformInsets.getSystemWindowInsetLeft(),
@@ -1090,10 +1055,9 @@
             return mSystemWindowInsets;
         }
 
-        @NonNull
         @Override
         @SuppressWarnings("deprecation")
-        WindowInsetsCompat inset(int left, int top, int right, int bottom) {
+        @NonNull WindowInsetsCompat inset(int left, int top, int right, int bottom) {
             Builder b = new Builder(toWindowInsetsCompat(mPlatformInsets));
             b.setSystemWindowInsets(insetInsets(getSystemWindowInsets(), left, top, right, bottom));
             b.setStableInsets(insetInsets(getStableInsets(), left, top, right, bottom));
@@ -1145,8 +1109,7 @@
          *
          * @return a copy of the provided view's AttachInfo.mVisibleRect or null if anything fails
          */
-        @Nullable
-        private Insets getVisibleInsets(@NonNull View rootView) {
+        private @Nullable Insets getVisibleInsets(@NonNull View rootView) {
             if (SDK_INT >= 30) {
                 throw new UnsupportedOperationException("getVisibleInsets() should not be called "
                         + "on API >= 30. Use WindowInsets.isVisible() instead.");
@@ -1237,21 +1200,18 @@
             return mPlatformInsets.isConsumed();
         }
 
-        @NonNull
         @Override
-        WindowInsetsCompat consumeStableInsets() {
+        @NonNull WindowInsetsCompat consumeStableInsets() {
             return toWindowInsetsCompat(mPlatformInsets.consumeStableInsets());
         }
 
-        @NonNull
         @Override
-        WindowInsetsCompat consumeSystemWindowInsets() {
+        @NonNull WindowInsetsCompat consumeSystemWindowInsets() {
             return toWindowInsetsCompat(mPlatformInsets.consumeSystemWindowInsets());
         }
 
         @Override
-        @NonNull
-        final Insets getStableInsets() {
+        final @NonNull Insets getStableInsets() {
             if (mStableInsets == null) {
                 mStableInsets = Insets.of(
                         mPlatformInsets.getStableInsetLeft(),
@@ -1279,15 +1239,13 @@
             super(host, other);
         }
 
-        @Nullable
         @Override
-        DisplayCutoutCompat getDisplayCutout() {
+        @Nullable DisplayCutoutCompat getDisplayCutout() {
             return DisplayCutoutCompat.wrap(mPlatformInsets.getDisplayCutout());
         }
 
-        @NonNull
         @Override
-        WindowInsetsCompat consumeDisplayCutout() {
+        @NonNull WindowInsetsCompat consumeDisplayCutout() {
             return toWindowInsetsCompat(mPlatformInsets.consumeDisplayCutout());
         }
 
@@ -1324,18 +1282,16 @@
             super(host, other);
         }
 
-        @NonNull
         @Override
-        Insets getSystemGestureInsets() {
+        @NonNull Insets getSystemGestureInsets() {
             if (mSystemGestureInsets == null) {
                 mSystemGestureInsets = toCompatInsets(mPlatformInsets.getSystemGestureInsets());
             }
             return mSystemGestureInsets;
         }
 
-        @NonNull
         @Override
-        Insets getMandatorySystemGestureInsets() {
+        @NonNull Insets getMandatorySystemGestureInsets() {
             if (mMandatorySystemGestureInsets == null) {
                 mMandatorySystemGestureInsets =
                         toCompatInsets(mPlatformInsets.getMandatorySystemGestureInsets());
@@ -1343,18 +1299,16 @@
             return mMandatorySystemGestureInsets;
         }
 
-        @NonNull
         @Override
-        Insets getTappableElementInsets() {
+        @NonNull Insets getTappableElementInsets() {
             if (mTappableElementInsets == null) {
                 mTappableElementInsets = toCompatInsets(mPlatformInsets.getTappableElementInsets());
             }
             return mTappableElementInsets;
         }
 
-        @NonNull
         @Override
-        WindowInsetsCompat inset(int left, int top, int right, int bottom) {
+        @NonNull WindowInsetsCompat inset(int left, int top, int right, int bottom) {
             return toWindowInsetsCompat(mPlatformInsets.inset(left, top, right, bottom));
         }
 
@@ -1377,8 +1331,8 @@
 
     @RequiresApi(30)
     private static class Impl30 extends Impl29 {
-        @NonNull
-        static final WindowInsetsCompat CONSUMED = toWindowInsetsCompat(WindowInsets.CONSUMED);
+        static final @NonNull WindowInsetsCompat CONSUMED =
+                toWindowInsetsCompat(WindowInsets.CONSUMED);
 
         Impl30(@NonNull WindowInsetsCompat host, @NonNull WindowInsets insets) {
             super(host, insets);
@@ -1388,17 +1342,15 @@
             super(host, other);
         }
 
-        @NonNull
         @Override
-        public Insets getInsets(int typeMask) {
+        public @NonNull Insets getInsets(int typeMask) {
             return toCompatInsets(
                     mPlatformInsets.getInsets(TypeImpl30.toPlatformType(typeMask))
             );
         }
 
-        @NonNull
         @Override
-        public Insets getInsetsIgnoringVisibility(int typeMask) {
+        public @NonNull Insets getInsetsIgnoringVisibility(int typeMask) {
             return toCompatInsets(
                     mPlatformInsets.getInsetsIgnoringVisibility(TypeImpl30.toPlatformType(typeMask))
             );
@@ -1420,8 +1372,8 @@
 
     @RequiresApi(34)
     private static class Impl34 extends Impl30 {
-        @NonNull
-        static final WindowInsetsCompat CONSUMED = toWindowInsetsCompat(WindowInsets.CONSUMED);
+        static final @NonNull WindowInsetsCompat CONSUMED =
+                toWindowInsetsCompat(WindowInsets.CONSUMED);
 
         Impl34(@NonNull WindowInsetsCompat host, @NonNull WindowInsets insets) {
             super(host, insets);
@@ -1431,17 +1383,15 @@
             super(host, other);
         }
 
-        @NonNull
         @Override
-        public Insets getInsets(int typeMask) {
+        public @NonNull Insets getInsets(int typeMask) {
             return toCompatInsets(
                     mPlatformInsets.getInsets(TypeImpl34.toPlatformType(typeMask))
             );
         }
 
-        @NonNull
         @Override
-        public Insets getInsetsIgnoringVisibility(int typeMask) {
+        public @NonNull Insets getInsetsIgnoringVisibility(int typeMask) {
             return toCompatInsets(
                     mPlatformInsets.getInsetsIgnoringVisibility(TypeImpl34.toPlatformType(typeMask))
             );
@@ -1507,8 +1457,7 @@
          * @deprecated Use {@link #setInsets(int, Insets)} with {@link Type#systemBars()}.
          */
         @Deprecated
-        @NonNull
-        public Builder setSystemWindowInsets(@NonNull Insets insets) {
+        public @NonNull Builder setSystemWindowInsets(@NonNull Insets insets) {
             mImpl.setSystemWindowInsets(insets);
             return this;
         }
@@ -1527,8 +1476,7 @@
          * @deprecated Use {@link #setInsets(int, Insets)} with {@link Type#systemGestures()}.
          */
         @Deprecated
-        @NonNull
-        public Builder setSystemGestureInsets(@NonNull Insets insets) {
+        public @NonNull Builder setSystemGestureInsets(@NonNull Insets insets) {
             mImpl.setSystemGestureInsets(insets);
             return this;
         }
@@ -1552,8 +1500,7 @@
          * {@link Type#mandatorySystemGestures()}.
          */
         @Deprecated
-        @NonNull
-        public Builder setMandatorySystemGestureInsets(@NonNull Insets insets) {
+        public @NonNull Builder setMandatorySystemGestureInsets(@NonNull Insets insets) {
             mImpl.setMandatorySystemGestureInsets(insets);
             return this;
         }
@@ -1571,8 +1518,7 @@
          * @deprecated Use {@link #setInsets(int, Insets)} with {@link Type#tappableElement()}.
          */
         @Deprecated
-        @NonNull
-        public Builder setTappableElementInsets(@NonNull Insets insets) {
+        public @NonNull Builder setTappableElementInsets(@NonNull Insets insets) {
             mImpl.setTappableElementInsets(insets);
             return this;
         }
@@ -1589,8 +1535,7 @@
          * @return itself
          * @see #getInsets(int)
          */
-        @NonNull
-        public Builder setInsets(@InsetsType int typeMask, @NonNull Insets insets) {
+        public @NonNull Builder setInsets(@InsetsType int typeMask, @NonNull Insets insets) {
             mImpl.setInsets(typeMask, insets);
             return this;
         }
@@ -1614,8 +1559,7 @@
          *                                  state of the IME.
          * @see #getInsetsIgnoringVisibility(int)
          */
-        @NonNull
-        public Builder setInsetsIgnoringVisibility(@InsetsType int typeMask,
+        public @NonNull Builder setInsetsIgnoringVisibility(@InsetsType int typeMask,
                 @NonNull Insets insets) {
             mImpl.setInsetsIgnoringVisibility(typeMask, insets);
             return this;
@@ -1629,8 +1573,7 @@
          * @return itself
          * @see #isVisible(int)
          */
-        @NonNull
-        public Builder setVisible(@InsetsType int typeMask, boolean visible) {
+        public @NonNull Builder setVisible(@InsetsType int typeMask, boolean visible) {
             mImpl.setVisible(typeMask, visible);
             return this;
         }
@@ -1652,8 +1595,7 @@
          * {@link Type#systemBars()}.
          */
         @Deprecated
-        @NonNull
-        public Builder setStableInsets(@NonNull Insets insets) {
+        public @NonNull Builder setStableInsets(@NonNull Insets insets) {
             mImpl.setStableInsets(insets);
             return this;
         }
@@ -1667,8 +1609,7 @@
          * @return itself
          * @see #getDisplayCutout()
          */
-        @NonNull
-        public Builder setDisplayCutout(@Nullable DisplayCutoutCompat displayCutout) {
+        public @NonNull Builder setDisplayCutout(@Nullable DisplayCutoutCompat displayCutout) {
             mImpl.setDisplayCutout(displayCutout);
             return this;
         }
@@ -1678,8 +1619,7 @@
          *
          * @return the {@link WindowInsetsCompat} instance.
          */
-        @NonNull
-        public WindowInsetsCompat build() {
+        public @NonNull WindowInsetsCompat build() {
             return mImpl.build();
         }
     }
@@ -1765,8 +1705,7 @@
             }
         }
 
-        @NonNull
-        WindowInsetsCompat build() {
+        @NonNull WindowInsetsCompat build() {
             applyInsetTypes();
             return mInsets;
         }
@@ -1810,8 +1749,7 @@
         }
 
         @Override
-        @NonNull
-        WindowInsetsCompat build() {
+        @NonNull WindowInsetsCompat build() {
             applyInsetTypes();
             WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(
                     mPlatformInsets);
@@ -1820,9 +1758,8 @@
             return windowInsetsCompat;
         }
 
-        @Nullable
         @SuppressWarnings("JavaReflectionMemberAccess")
-        private static WindowInsets createWindowInsetsInstance() {
+        private static @Nullable WindowInsets createWindowInsetsInstance() {
             // On API 20-28, there is no public way to create an WindowInsets instance, so we
             // need to use reflection.
 
@@ -1922,8 +1859,7 @@
         }
 
         @Override
-        @NonNull
-        WindowInsetsCompat build() {
+        @NonNull WindowInsetsCompat build() {
             applyInsetTypes();
             WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(
                     mPlatBuilder.build());
@@ -2344,8 +2280,7 @@
 
         // Only called on SDK 21 and 22
         @SuppressWarnings("deprecation")
-        @Nullable
-        public static WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
+        public static @Nullable WindowInsetsCompat getRootWindowInsets(@NonNull View v) {
             if (!sReflectionSucceeded || !v.isAttachedToWindow()) {
                 return null;
             }
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsControllerCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsControllerCompat.java
index e74ec94..d6d7aa3 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsControllerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsControllerCompat.java
@@ -31,14 +31,15 @@
 import android.view.animation.Interpolator;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.collection.SimpleArrayMap;
 import androidx.core.graphics.Insets;
 import androidx.core.view.WindowInsetsCompat.Type.InsetsType;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.concurrent.TimeUnit;
@@ -151,10 +152,9 @@
      * {@link WindowInsetsControllerCompat}
      * @deprecated Use {@link WindowCompat#getInsetsController(Window, View)} instead
      */
-    @NonNull
     @RequiresApi(30)
     @Deprecated
-    public static WindowInsetsControllerCompat toWindowInsetsControllerCompat(
+    public static @NonNull WindowInsetsControllerCompat toWindowInsetsControllerCompat(
             @NonNull WindowInsetsController insetsController) {
         return new WindowInsetsControllerCompat(insetsController);
     }
@@ -340,7 +340,7 @@
      *WindowInsetsControllerCompat.OnControllableInsetsChangedListener)
      */
     public void addOnControllableInsetsChangedListener(
-            @NonNull WindowInsetsControllerCompat.OnControllableInsetsChangedListener listener) {
+            WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener listener) {
         mImpl.addOnControllableInsetsChangedListener(listener);
     }
 
@@ -354,7 +354,7 @@
      *WindowInsetsControllerCompat.OnControllableInsetsChangedListener)
      */
     public void removeOnControllableInsetsChangedListener(
-            @NonNull WindowInsetsControllerCompat.OnControllableInsetsChangedListener
+            WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener
                     listener) {
         mImpl.removeOnControllableInsetsChangedListener(listener);
     }
@@ -436,7 +436,7 @@
         }
 
         void removeOnControllableInsetsChangedListener(
-                @NonNull WindowInsetsControllerCompat.OnControllableInsetsChangedListener
+                WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener
                         listener) {
         }
     }
@@ -444,11 +444,9 @@
     @RequiresApi(20)
     private static class Impl20 extends Impl {
 
-        @NonNull
-        protected final Window mWindow;
+        protected final @NonNull Window mWindow;
 
-        @NonNull
-        private final SoftwareKeyboardControllerCompat mSoftwareKeyboardControllerCompat;
+        private final @NonNull SoftwareKeyboardControllerCompat mSoftwareKeyboardControllerCompat;
 
         Impl20(@NonNull Window window,
                 @NonNull SoftwareKeyboardControllerCompat softwareKeyboardControllerCompat) {
@@ -565,7 +563,7 @@
 
         @Override
         void removeOnControllableInsetsChangedListener(
-                @NonNull WindowInsetsControllerCompat.OnControllableInsetsChangedListener
+                WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener
                         listener) {
         }
     }
@@ -738,7 +736,7 @@
         void controlWindowInsetsAnimation(@InsetsType int types, long durationMillis,
                 @Nullable Interpolator interpolator,
                 @Nullable CancellationSignal cancellationSignal,
-                @NonNull final WindowInsetsAnimationControlListenerCompat listener) {
+                final @NonNull WindowInsetsAnimationControlListenerCompat listener) {
 
             WindowInsetsAnimationControlListener fwListener =
                     new WindowInsetsAnimationControlListener() {
@@ -825,7 +823,7 @@
 
         @Override
         void addOnControllableInsetsChangedListener(
-                @NonNull final WindowInsetsControllerCompat.OnControllableInsetsChangedListener
+                final WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener
                         listener) {
 
             if (mListeners.containsKey(listener)) {
@@ -845,7 +843,7 @@
 
         @Override
         void removeOnControllableInsetsChangedListener(
-                @NonNull WindowInsetsControllerCompat.OnControllableInsetsChangedListener
+                WindowInsetsControllerCompat.@NonNull OnControllableInsetsChangedListener
                         listener) {
             WindowInsetsController.OnControllableInsetsChangedListener
                     fwListener = mListeners.remove(listener);
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityClickableSpanCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityClickableSpanCompat.java
index 543b36a..d390c49 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityClickableSpanCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityClickableSpanCompat.java
@@ -21,9 +21,10 @@
 import android.text.style.ClickableSpan;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * {@link ClickableSpan} cannot be parceled, but accessibility services need to be able to cause
  * their callback handlers to be called. This class serves as a placeholder for the
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityEventCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityEventCompat.java
index 307aa6b..4fc0997 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityEventCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityEventCompat.java
@@ -26,10 +26,11 @@
 import android.widget.EditText;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityManagerCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityManagerCompat.java
index 12be024..e80ecf8 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityManagerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityManagerCompat.java
@@ -20,9 +20,10 @@
 import android.os.Build;
 import android.view.accessibility.AccessibilityManager;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 
 /**
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java
index c452246..e594a8c 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java
@@ -43,8 +43,6 @@
 import android.view.accessibility.AccessibilityNodeInfo.TouchDelegateInfo;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.OptIn;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
@@ -60,6 +58,9 @@
 import androidx.core.view.accessibility.AccessibilityViewCommand.SetSelectionArguments;
 import androidx.core.view.accessibility.AccessibilityViewCommand.SetTextArguments;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 import java.time.Duration;
 import java.util.ArrayList;
@@ -516,8 +517,7 @@
         /**
          * Action to move to the page above.
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_PAGE_UP =
+        public static final @NonNull AccessibilityActionCompat ACTION_PAGE_UP =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 29
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_UP : null,
                         android.R.id.accessibilityActionPageUp, null, null, null);
@@ -525,8 +525,7 @@
         /**
          * Action to move to the page below.
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_PAGE_DOWN =
+        public static final @NonNull AccessibilityActionCompat ACTION_PAGE_DOWN =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 29
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_DOWN : null,
                         android.R.id.accessibilityActionPageDown, null, null, null);
@@ -534,8 +533,7 @@
         /**
          * Action to move to the page left.
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_PAGE_LEFT =
+        public static final @NonNull AccessibilityActionCompat ACTION_PAGE_LEFT =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 29
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT : null,
                         android.R.id.accessibilityActionPageLeft, null, null, null);
@@ -543,8 +541,7 @@
         /**
          * Action to move to the page right.
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_PAGE_RIGHT =
+        public static final @NonNull AccessibilityActionCompat ACTION_PAGE_RIGHT =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 29
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT : null,
                         android.R.id.accessibilityActionPageRight, null, null, null);
@@ -640,7 +637,7 @@
          *     delays will lead to unexpected UI behavior.
          * <p>
          */
-        @NonNull public static final AccessibilityActionCompat ACTION_PRESS_AND_HOLD =
+        public static final @NonNull AccessibilityActionCompat ACTION_PRESS_AND_HOLD =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 30
                         ? AccessibilityNodeInfo.AccessibilityAction.ACTION_PRESS_AND_HOLD : null,
                         android.R.id.accessibilityActionPressAndHold, null, null, null);
@@ -653,7 +650,7 @@
          * actionId has set. A node should expose this action only for views that are currently
          * with input focus and editable.
          */
-        @NonNull public static final AccessibilityActionCompat ACTION_IME_ENTER =
+        public static final @NonNull AccessibilityActionCompat ACTION_IME_ENTER =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 30
                         ? AccessibilityNodeInfo.AccessibilityAction.ACTION_IME_ENTER : null,
                         android.R.id.accessibilityActionImeEnter, null, null, null);
@@ -669,8 +666,7 @@
          *
          * @see AccessibilityEventCompat#CONTENT_CHANGE_TYPE_DRAG_STARTED
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_DRAG_START =
+        public static final @NonNull AccessibilityActionCompat ACTION_DRAG_START =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 32
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_DRAG_START : null,
                         android.R.id.accessibilityActionDragStart, null, null, null);
@@ -686,8 +682,7 @@
          *
          * @see AccessibilityEventCompat#CONTENT_CHANGE_TYPE_DRAG_DROPPED
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_DRAG_DROP =
+        public static final @NonNull AccessibilityActionCompat ACTION_DRAG_DROP =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 32
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_DRAG_DROP : null,
                         android.R.id.accessibilityActionDragDrop, null, null, null);
@@ -700,8 +695,7 @@
          *
          * @see AccessibilityEventCompat#CONTENT_CHANGE_TYPE_DRAG_CANCELLED
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_DRAG_CANCEL =
+        public static final @NonNull AccessibilityActionCompat ACTION_DRAG_CANCEL =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 32
                         ?  AccessibilityNodeInfo.AccessibilityAction.ACTION_DRAG_CANCEL : null,
                         android.R.id.accessibilityActionDragCancel, null, null, null);
@@ -709,8 +703,7 @@
         /**
          * Action to show suggestions for editable text.
          */
-        @NonNull
-        public static final AccessibilityActionCompat ACTION_SHOW_TEXT_SUGGESTIONS =
+        public static final @NonNull AccessibilityActionCompat ACTION_SHOW_TEXT_SUGGESTIONS =
                 new AccessibilityActionCompat(Build.VERSION.SDK_INT >= 33
                         ?   AccessibilityNodeInfo.AccessibilityAction.ACTION_SHOW_TEXT_SUGGESTIONS
                         :   null, android.R.id.accessibilityActionShowTextSuggestions, null,
@@ -739,9 +732,8 @@
          *     required argument.<br>
          * </p>
          */
-        @NonNull
         @OptIn(markerClass = androidx.core.os.BuildCompat.PrereleaseSdkCheck.class)
-        public static final AccessibilityActionCompat ACTION_SCROLL_IN_DIRECTION =
+        public static final @NonNull AccessibilityActionCompat ACTION_SCROLL_IN_DIRECTION =
                 new AccessibilityActionCompat(
                         Build.VERSION.SDK_INT >= 34 ? Api34Impl.getActionScrollInDirection() : null,
                         android.R.id.accessibilityActionScrollInDirection, null, null, null);
@@ -880,9 +872,8 @@
             return true;
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             StringBuilder builder = new StringBuilder();
             builder.append("AccessibilityActionCompat: ");
             // Mirror AccessibilityNodeInfoCompat.toString's action string.
@@ -1101,8 +1092,7 @@
              * @param rowCount The number of rows in the collection.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setRowCount(int rowCount) {
+            public CollectionInfoCompat.@NonNull Builder setRowCount(int rowCount) {
                 mRowCount = rowCount;
                 return this;
             }
@@ -1112,8 +1102,7 @@
              * @param columnCount The number of columns in the collection.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setColumnCount(int columnCount) {
+            public CollectionInfoCompat.@NonNull Builder setColumnCount(int columnCount) {
                 mColumnCount = columnCount;
                 return this;
             }
@@ -1122,8 +1111,7 @@
              * @param hierarchical Whether the collection is hierarchical.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setHierarchical(boolean hierarchical) {
+            public CollectionInfoCompat.@NonNull Builder setHierarchical(boolean hierarchical) {
                 mHierarchical = hierarchical;
                 return this;
             }
@@ -1133,8 +1121,7 @@
              * @param selectionMode The selection mode.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setSelectionMode(int selectionMode) {
+            public CollectionInfoCompat.@NonNull Builder setSelectionMode(int selectionMode) {
                 mSelectionMode = selectionMode;
                 return this;
             }
@@ -1147,8 +1134,7 @@
              *                  {@code UNDEFINED} if the item count is not known.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setItemCount(int itemCount) {
+            public CollectionInfoCompat.@NonNull Builder setItemCount(int itemCount) {
                 mItemCount = itemCount;
                 return this;
             }
@@ -1159,8 +1145,7 @@
              *                                            accessibility.
              * @return This builder.
              */
-            @NonNull
-            public CollectionInfoCompat.Builder setImportantForAccessibilityItemCount(
+            public CollectionInfoCompat.@NonNull Builder setImportantForAccessibilityItemCount(
                     int importantForAccessibilityItemCount) {
                 mImportantForAccessibilityItemCount = importantForAccessibilityItemCount;
                 return this;
@@ -1169,8 +1154,7 @@
             /**
              * Creates a new {@link CollectionInfoCompat} instance.
              */
-            @NonNull
-            public CollectionInfoCompat build() {
+            public @NonNull CollectionInfoCompat build() {
                 if (Build.VERSION.SDK_INT >= 35) {
                     return Api35Impl.buildCollectionInfoCompat(mRowCount, mColumnCount,
                             mHierarchical, mSelectionMode, mItemCount,
@@ -1301,8 +1285,7 @@
          *
          * @return The row title.
          */
-        @Nullable
-        public String getRowTitle() {
+        public @Nullable String getRowTitle() {
             if (Build.VERSION.SDK_INT >= 33) {
                 return Api33Impl.getCollectionItemRowTitle(mInfo);
             } else {
@@ -1315,8 +1298,7 @@
          *
          * @return The column title.
          */
-        @Nullable
-        public String getColumnTitle() {
+        public @Nullable String getColumnTitle() {
             if (Build.VERSION.SDK_INT >= 33) {
                 return Api33Impl.getCollectionItemColumnTitle(mInfo);
             } else {
@@ -1349,8 +1331,7 @@
              * @param heading The heading state
              * @return This builder
              */
-            @NonNull
-            public Builder setHeading(boolean heading) {
+            public @NonNull Builder setHeading(boolean heading) {
                 mHeading = heading;
                 return this;
             }
@@ -1361,8 +1342,7 @@
              * @param columnIndex The column index
              * @return This builder
              */
-            @NonNull
-            public Builder setColumnIndex(int columnIndex) {
+            public @NonNull Builder setColumnIndex(int columnIndex) {
                 mColumnIndex = columnIndex;
                 return this;
             }
@@ -1373,8 +1353,7 @@
              * @param rowIndex The row index
              * @return This builder
              */
-            @NonNull
-            public Builder setRowIndex(int rowIndex) {
+            public @NonNull Builder setRowIndex(int rowIndex) {
                 mRowIndex = rowIndex;
                 return this;
             }
@@ -1385,8 +1364,7 @@
              * @param columnSpan The number of columns spans
              * @return This builder
              */
-            @NonNull
-            public Builder setColumnSpan(int columnSpan) {
+            public @NonNull Builder setColumnSpan(int columnSpan) {
                 mColumnSpan = columnSpan;
                 return this;
             }
@@ -1397,8 +1375,7 @@
              * @param rowSpan The number of rows spans
              * @return This builder
              */
-            @NonNull
-            public Builder setRowSpan(int rowSpan) {
+            public @NonNull Builder setRowSpan(int rowSpan) {
                 mRowSpan = rowSpan;
                 return this;
             }
@@ -1409,8 +1386,7 @@
              * @param selected The number of rows spans
              * @return This builder
              */
-            @NonNull
-            public Builder setSelected(boolean selected) {
+            public @NonNull Builder setSelected(boolean selected) {
                 mSelected = selected;
                 return this;
             }
@@ -1421,8 +1397,7 @@
              * @param rowTitle The row title
              * @return This builder
              */
-            @NonNull
-            public Builder setRowTitle(@Nullable String rowTitle) {
+            public @NonNull Builder setRowTitle(@Nullable String rowTitle) {
                 mRowTitle = rowTitle;
                 return this;
             }
@@ -1433,8 +1408,7 @@
              * @param columnTitle The column title
              * @return This builder
              */
-            @NonNull
-            public Builder setColumnTitle(@Nullable String columnTitle) {
+            public @NonNull Builder setColumnTitle(@Nullable String columnTitle) {
                 mColumnTitle = columnTitle;
                 return this;
             }
@@ -1442,8 +1416,7 @@
             /**
              * Builds and returns a {@link AccessibilityNodeInfo.CollectionItemInfo}.
              */
-            @NonNull
-            public CollectionItemInfoCompat build() {
+            public @NonNull CollectionItemInfoCompat build() {
                 if (Build.VERSION.SDK_INT >= 33) {
                     return Api33Impl.buildCollectionItemInfoCompat(mHeading, mColumnIndex,
                             mRowIndex, mColumnSpan, mRowSpan, mSelected, mRowTitle, mColumnTitle);
@@ -1597,8 +1570,7 @@
          * @param index The desired index, must be between 0 and {@link #getRegionCount()}-1.
          * @return Returns the {@link Region} stored at the given index.
          */
-        @Nullable
-        public Region getRegionAt(@IntRange(from = 0) int index) {
+        public @Nullable Region getRegionAt(@IntRange(from = 0) int index) {
             if (Build.VERSION.SDK_INT >= 29) {
                 return mInfo.getRegionAt(index);
             }
@@ -1620,8 +1592,7 @@
          * @param region The region retrieved from {@link #getRegionAt(int)}.
          * @return The target node associates with the given region.
          */
-        @Nullable
-        public AccessibilityNodeInfoCompat getTargetForRegion(@NonNull Region region) {
+        public @Nullable AccessibilityNodeInfoCompat getTargetForRegion(@NonNull Region region) {
             if (Build.VERSION.SDK_INT >= 29) {
                 AccessibilityNodeInfo info = mInfo.getTargetForRegion(region);
                 if (info != null) {
@@ -2533,8 +2504,7 @@
      *
      * @see AccessibilityNodeInfoCompat#getParent(int) for a description of prefetching.
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat getChild(int index, int prefetchingStrategy) {
+    public @Nullable AccessibilityNodeInfoCompat getChild(int index, int prefetchingStrategy) {
         if (Build.VERSION.SDK_INT >= 33) {
             return Api33Impl.getChild(mInfo, index, prefetchingStrategy);
         }
@@ -2793,8 +2763,7 @@
      * @see #FLAG_PREFETCH_SIBLINGS
      * @see #FLAG_PREFETCH_UNINTERRUPTIBLE
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat getParent(int prefetchingStrategy) {
+    public @Nullable AccessibilityNodeInfoCompat getParent(int prefetchingStrategy) {
         if (Build.VERSION.SDK_INT >= 33) {
             return Api33Impl.getParent(mInfo, prefetchingStrategy);
         }
@@ -2923,7 +2892,7 @@
      * </ul>
      * @param outBounds The output node bounds.
      */
-    public void getBoundsInWindow(@NonNull  Rect outBounds) {
+    public void getBoundsInWindow(@NonNull Rect outBounds) {
         if (Build.VERSION.SDK_INT >= 34) {
             Api34Impl.getBoundsInWindow(mInfo, outBounds);
         } else {
@@ -3766,8 +3735,7 @@
      * </ul>
      * @see #setContainerTitle for details.
      */
-    @Nullable
-    public CharSequence getContainerTitle() {
+    public @Nullable CharSequence getContainerTitle() {
         if (Build.VERSION.SDK_INT >= 34) {
             return Api34Impl.getContainerTitle(mInfo);
         } else {
@@ -3967,8 +3935,7 @@
      * @return The {@link android.view.accessibility.AccessibilityNodeInfo.ExtraRenderingInfo
      * extra rendering info}.
      */
-    @Nullable
-    public AccessibilityNodeInfo.ExtraRenderingInfo getExtraRenderingInfo() {
+    public AccessibilityNodeInfo.@Nullable ExtraRenderingInfo getExtraRenderingInfo() {
         if (Build.VERSION.SDK_INT >= 33) {
             return Api33Impl.getExtraRenderingInfo(mInfo);
         } else {
@@ -4293,8 +4260,7 @@
      * @see #EXTRA_DATA_RENDERING_INFO_KEY
      * @see #EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
      */
-    @NonNull
-    public List<String> getAvailableExtraData() {
+    public @NonNull List<String> getAvailableExtraData() {
         if (Build.VERSION.SDK_INT >= 26) {
             return mInfo.getAvailableExtraData();
         } else {
@@ -4603,8 +4569,7 @@
      *
      * @return The tooltip text.
      */
-    @Nullable
-    public CharSequence getTooltipText() {
+    public @Nullable CharSequence getTooltipText() {
         if (Build.VERSION.SDK_INT >= 28) {
             return mInfo.getTooltipText();
         } else {
@@ -4912,8 +4877,7 @@
      * @return {@link TouchDelegateInfoCompat} or {@code null} if there are no touch delegates
      * in this node.
      */
-    @Nullable
-    public TouchDelegateInfoCompat getTouchDelegateInfo() {
+    public @Nullable TouchDelegateInfoCompat getTouchDelegateInfo() {
         if (Build.VERSION.SDK_INT >= 29) {
             TouchDelegateInfo delegateInfo = mInfo.getTouchDelegateInfo();
             if (delegateInfo != null) {
@@ -5033,9 +4997,8 @@
     }
 
     @SuppressWarnings("deprecation")
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         StringBuilder builder = new StringBuilder();
         builder.append(super.toString());
 
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeProviderCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeProviderCompat.java
index 6d44092..7d52a97 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeProviderCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeProviderCompat.java
@@ -21,10 +21,11 @@
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeProvider;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -109,8 +110,7 @@
      */
     public static final int HOST_VIEW_ID = -1;
 
-    @Nullable
-    private final Object mProvider;
+    private final @Nullable Object mProvider;
 
     /**
      * Creates a new instance.
@@ -136,8 +136,7 @@
     /**
      * @return The wrapped {@link android.view.accessibility.AccessibilityNodeProvider}.
      */
-    @Nullable
-    public Object getProvider() {
+    public @Nullable Object getProvider() {
         return mProvider;
     }
 
@@ -163,8 +162,7 @@
      *
      * @see AccessibilityNodeInfoCompat
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
+    public @Nullable AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
         return null;
     }
 
@@ -201,9 +199,8 @@
      * @see AccessibilityNodeInfoCompat
      */
     @SuppressWarnings("unused")
-    @Nullable
-    public List<AccessibilityNodeInfoCompat> findAccessibilityNodeInfosByText(@NonNull String text,
-            int virtualViewId) {
+    public @Nullable List<AccessibilityNodeInfoCompat> findAccessibilityNodeInfosByText(
+            @NonNull String text, int virtualViewId) {
         return null;
     }
 
@@ -219,8 +216,7 @@
      * @see AccessibilityNodeInfoCompat#FOCUS_ACCESSIBILITY
      */
     @SuppressWarnings("unused")
-    @Nullable
-    public AccessibilityNodeInfoCompat findFocus(int focus) {
+    public @Nullable AccessibilityNodeInfoCompat findFocus(int focus) {
         return null;
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityRecordCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityRecordCompat.java
index cf9d33f..8c01131 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityRecordCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityRecordCompat.java
@@ -23,8 +23,8 @@
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityRecord;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.List;
 
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityViewCommand.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityViewCommand.java
index b020b83..9f24878 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityViewCommand.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityViewCommand.java
@@ -21,10 +21,11 @@
 import android.os.Bundle;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Functional interface used to create a custom accessibility action.
  */
@@ -38,7 +39,7 @@
      * @param arguments Optional action arguments
      */
     boolean perform(@NonNull View view,
-            @Nullable AccessibilityViewCommand.CommandArguments arguments);
+            AccessibilityViewCommand.@Nullable CommandArguments arguments);
 
     /**
      * Object containing arguments passed into an {@link AccessibilityViewCommand}
@@ -99,8 +100,7 @@
         /**
          * @return HTML element type, for example BUTTON, INPUT, TABLE, etc.
          */
-        @Nullable
-        public String getHTMLElement() {
+        public @Nullable String getHTMLElement() {
             return mBundle.getString(
                     AccessibilityNodeInfoCompat.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
         }
@@ -135,8 +135,7 @@
         /**
          * @return The text content to set.
          */
-        @Nullable
-        public CharSequence getText() {
+        public @Nullable CharSequence getText() {
             return mBundle.getCharSequence(
                     AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
         }
diff --git a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityWindowInfoCompat.java b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityWindowInfoCompat.java
index ecb83c1..8e3103f 100644
--- a/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityWindowInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityWindowInfoCompat.java
@@ -27,11 +27,12 @@
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityWindowInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.os.LocaleListCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing {@link android.view.accessibility.AccessibilityWindowInfo}.
  */
@@ -155,8 +156,7 @@
      *
      * @return The root node.
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat getRoot() {
+    public @Nullable AccessibilityNodeInfoCompat getRoot() {
         if (SDK_INT >= 21) {
             return AccessibilityNodeInfoCompat.wrapNonNullInstance(
                     Api21Impl.getRoot((AccessibilityWindowInfo) mInfo));
@@ -173,8 +173,7 @@
      *
      * @see AccessibilityNodeInfoCompat#getParent(int) for a description of prefetching.
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat getRoot(int prefetchingStrategy) {
+    public @Nullable AccessibilityNodeInfoCompat getRoot(int prefetchingStrategy) {
         if (Build.VERSION.SDK_INT >= 33) {
             return Api33Impl.getRoot(mInfo, prefetchingStrategy);
         }
@@ -203,8 +202,7 @@
      *
      * @return The parent window.
      */
-    @Nullable
-    public AccessibilityWindowInfoCompat getParent() {
+    public @Nullable AccessibilityWindowInfoCompat getParent() {
         if (SDK_INT >= 21) {
             return wrapNonNullInstance(Api21Impl.getParent((AccessibilityWindowInfo) mInfo));
         } else {
@@ -322,8 +320,7 @@
      * @param index The index.
      * @return The child.
      */
-    @Nullable
-    public AccessibilityWindowInfoCompat getChild(int index) {
+    public @Nullable AccessibilityWindowInfoCompat getChild(int index) {
         if (SDK_INT >= 21) {
             return wrapNonNullInstance(Api21Impl.getChild((AccessibilityWindowInfo) mInfo, index));
         } else {
@@ -390,8 +387,7 @@
      * @return The title of the window, or the application label for the window if no title was
      * explicitly set, or {@code null} if neither is available.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         if (SDK_INT >= 24) {
             return Api24Impl.getTitle((AccessibilityWindowInfo) mInfo);
         } else {
@@ -404,8 +400,7 @@
      *
      * @return The anchor node, or {@code null} if none exists.
      */
-    @Nullable
-    public AccessibilityNodeInfoCompat getAnchor() {
+    public @Nullable AccessibilityNodeInfoCompat getAnchor() {
         if (SDK_INT >= 24) {
             return AccessibilityNodeInfoCompat.wrapNonNullInstance(
                     Api24Impl.getAnchor((AccessibilityWindowInfo) mInfo));
@@ -420,8 +415,7 @@
      *
      * @return An instance.
      */
-    @Nullable
-    public static AccessibilityWindowInfoCompat obtain() {
+    public static @Nullable AccessibilityWindowInfoCompat obtain() {
         if (SDK_INT >= 21) {
             return wrapNonNullInstance(Api21Impl.obtain());
         } else {
@@ -437,8 +431,7 @@
      * @param info The other info.
      * @return An instance.
      */
-    @Nullable
-    public static AccessibilityWindowInfoCompat obtain(
+    public static @Nullable AccessibilityWindowInfoCompat obtain(
             @Nullable AccessibilityWindowInfoCompat info) {
         if (SDK_INT >= 21) {
             return info == null
@@ -464,8 +457,7 @@
     /**
      * @return The unwrapped {@link android.view.accessibility.AccessibilityWindowInfo}.
      */
-    @Nullable
-    public AccessibilityWindowInfo unwrap() {
+    public @Nullable AccessibilityWindowInfo unwrap() {
         if (SDK_INT >= 21) {
             return (AccessibilityWindowInfo) mInfo;
         } else {
@@ -496,9 +488,8 @@
         return mInfo.equals(other.mInfo);
     }
 
-    @NonNull
     @Override
-    public String toString() {
+    public @NonNull String toString() {
         StringBuilder builder = new StringBuilder();
         Rect bounds = new Rect();
         getBoundsInScreen(bounds);
diff --git a/core/core/src/main/java/androidx/core/view/animation/PathInterpolatorCompat.java b/core/core/src/main/java/androidx/core/view/animation/PathInterpolatorCompat.java
index fe65e9f..d426319 100644
--- a/core/core/src/main/java/androidx/core/view/animation/PathInterpolatorCompat.java
+++ b/core/core/src/main/java/androidx/core/view/animation/PathInterpolatorCompat.java
@@ -21,9 +21,10 @@
 import android.view.animation.Interpolator;
 import android.view.animation.PathInterpolator;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for creating path-based {@link Interpolator} instances. On API 21 or newer, the
  * platform implementation will be used and on older platforms a compatible alternative
@@ -47,8 +48,7 @@
      * @param path the {@link Path} to use to make the line representing the {@link Interpolator}
      * @return the {@link Interpolator} representing the {@link Path}
      */
-    @NonNull
-    public static Interpolator create(@NonNull Path path) {
+    public static @NonNull Interpolator create(@NonNull Path path) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.createPathInterpolator(path);
         }
@@ -63,8 +63,7 @@
      * @param controlY the y coordinate of the quadratic Bezier control point
      * @return the {@link Interpolator} representing the quadratic Bezier curve
      */
-    @NonNull
-    public static Interpolator create(float controlX, float controlY) {
+    public static @NonNull Interpolator create(float controlX, float controlY) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.createPathInterpolator(controlX, controlY);
         }
@@ -81,8 +80,7 @@
      * @param controlY2 the y coordinate of the second control point of the cubic Bezier
      * @return the {@link Interpolator} representing the cubic Bezier curve
      */
-    @NonNull
-    public static Interpolator create(float controlX1, float controlY1,
+    public static @NonNull Interpolator create(float controlX1, float controlY1,
             float controlX2, float controlY2) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.createPathInterpolator(controlX1, controlY1, controlX2, controlY2);
diff --git a/core/core/src/main/java/androidx/core/view/autofill/AutofillIdCompat.java b/core/core/src/main/java/androidx/core/view/autofill/AutofillIdCompat.java
index ccf9824..6ef7072 100644
--- a/core/core/src/main/java/androidx/core/view/autofill/AutofillIdCompat.java
+++ b/core/core/src/main/java/androidx/core/view/autofill/AutofillIdCompat.java
@@ -18,9 +18,10 @@
 
 import android.view.autofill.AutofillId;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper for accessing features in {@link AutofillId}.
  */
@@ -43,8 +44,7 @@
      * @return wrapped class
      */
     @RequiresApi(26)
-    @NonNull
-    public static AutofillIdCompat toAutofillIdCompat(@NonNull AutofillId autofillId) {
+    public static @NonNull AutofillIdCompat toAutofillIdCompat(@NonNull AutofillId autofillId) {
         return new AutofillIdCompat(autofillId);
     }
 
@@ -58,8 +58,7 @@
      * @see AutofillIdCompat#toAutofillIdCompat(AutofillId)
      */
     @RequiresApi(26)
-    @NonNull
-    public AutofillId toAutofillId() {
+    public @NonNull AutofillId toAutofillId() {
         return (AutofillId) mWrappedObj;
     }
 }
diff --git a/core/core/src/main/java/androidx/core/view/contentcapture/ContentCaptureSessionCompat.java b/core/core/src/main/java/androidx/core/view/contentcapture/ContentCaptureSessionCompat.java
index 0af5435..f900db3 100644
--- a/core/core/src/main/java/androidx/core/view/contentcapture/ContentCaptureSessionCompat.java
+++ b/core/core/src/main/java/androidx/core/view/contentcapture/ContentCaptureSessionCompat.java
@@ -24,12 +24,13 @@
 import android.view.autofill.AutofillId;
 import android.view.contentcapture.ContentCaptureSession;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.ViewStructureCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 import java.util.Objects;
 
@@ -55,8 +56,7 @@
      * @return wrapped class
      */
     @RequiresApi(29)
-    @NonNull
-    public static ContentCaptureSessionCompat toContentCaptureSessionCompat(
+    public static @NonNull ContentCaptureSessionCompat toContentCaptureSessionCompat(
             @NonNull ContentCaptureSession contentCaptureSession, @NonNull View host) {
         return new ContentCaptureSessionCompat(contentCaptureSession, host);
     }
@@ -71,8 +71,7 @@
      * @see ContentCaptureSessionCompat#toContentCaptureSessionCompat(ContentCaptureSession, View)
      */
     @RequiresApi(29)
-    @NonNull
-    public ContentCaptureSession toContentCaptureSession() {
+    public @NonNull ContentCaptureSession toContentCaptureSession() {
         return (ContentCaptureSession) mWrappedObj;
     }
 
@@ -103,8 +102,7 @@
      *
      * @return {@link AutofillId} for the virtual child
      */
-    @Nullable
-    public AutofillId newAutofillId(long virtualChildId) {
+    public @Nullable AutofillId newAutofillId(long virtualChildId) {
         if (SDK_INT >= 29) {
             return Api29Impl.newAutofillId(
                     (ContentCaptureSession) mWrappedObj,
@@ -130,8 +128,7 @@
      *
      * @return a new {@link ViewStructure} that can be used for Content Capture purposes.
      */
-    @Nullable
-    public ViewStructureCompat newVirtualViewStructure(
+    public @Nullable ViewStructureCompat newVirtualViewStructure(
             @NonNull AutofillId parentId, long virtualId) {
         if (SDK_INT >= 29) {
             return ViewStructureCompat.toViewStructureCompat(
@@ -194,7 +191,7 @@
      *
      * @param virtualIds ids of the virtual children.
      */
-    public void notifyViewsDisappeared(@NonNull long[] virtualIds) {
+    public void notifyViewsDisappeared(long @NonNull [] virtualIds) {
         if (SDK_INT >= 34) {
             Api29Impl.notifyViewsDisappeared(
                     (ContentCaptureSession) mWrappedObj,
diff --git a/core/core/src/main/java/androidx/core/view/inputmethod/EditorInfoCompat.java b/core/core/src/main/java/androidx/core/view/inputmethod/EditorInfoCompat.java
index 22a15e9..61938b4 100644
--- a/core/core/src/main/java/androidx/core/view/inputmethod/EditorInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/view/inputmethod/EditorInfoCompat.java
@@ -37,12 +37,13 @@
 import android.view.inputmethod.InputMethodManager;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 
 /**
@@ -160,7 +161,7 @@
      *                         is not supported on this Editor
      */
     public static void setContentMimeTypes(@NonNull EditorInfo editorInfo,
-            @Nullable String[] contentMimeTypes) {
+            String @Nullable [] contentMimeTypes) {
         if (Build.VERSION.SDK_INT >= 25) {
             editorInfo.contentMimeTypes = contentMimeTypes;
         } else {
@@ -182,8 +183,7 @@
      * InputConnectionCompat#commitContent(InputConnection, EditorInfo, InputContentInfoCompat,
      * int, Bundle)} is not supported on this editor
      */
-    @NonNull
-    public static String[] getContentMimeTypes(@NonNull EditorInfo editorInfo) {
+    public static String @NonNull [] getContentMimeTypes(@NonNull EditorInfo editorInfo) {
         if (Build.VERSION.SDK_INT >= 25) {
             final String[] result = editorInfo.contentMimeTypes;
             return result != null ? result : EMPTY_STRING_ARRAY;
@@ -395,8 +395,7 @@
      * than <var>n</var>. When there is no text before the cursor, an empty string will be returned.
      * It could also be {@code null} when the editor or system could not support this protocol.
      */
-    @Nullable
-    public static CharSequence getInitialTextBeforeCursor(@NonNull EditorInfo editorInfo,
+    public static @Nullable CharSequence getInitialTextBeforeCursor(@NonNull EditorInfo editorInfo,
             int length, int flags) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
             return Api30Impl.getInitialTextBeforeCursor(editorInfo, length, flags);
@@ -432,8 +431,8 @@
      * is no text selected. When {@code null} is returned, the selected text might be too long or
      * this protocol is not supported.
      */
-    @Nullable
-    public static CharSequence getInitialSelectedText(@NonNull EditorInfo editorInfo, int flags) {
+    public static @Nullable CharSequence getInitialSelectedText(@NonNull EditorInfo editorInfo,
+            int flags) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
             return Api30Impl.getInitialSelectedText(editorInfo, flags);
         }
@@ -478,9 +477,8 @@
      * than <var>n</var>. When there is no text after the cursor, an empty string will be returned.
      * It could also be {@code null} when the editor or system could not support this protocol.
      */
-    @Nullable
-    public static CharSequence getInitialTextAfterCursor(@NonNull EditorInfo editorInfo, int length,
-            int flags) {
+    public static @Nullable CharSequence getInitialTextAfterCursor(@NonNull EditorInfo editorInfo,
+            int length, int flags) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
             return Api30Impl.getInitialTextAfterCursor(editorInfo, length, flags);
         }
diff --git a/core/core/src/main/java/androidx/core/view/inputmethod/InputConnectionCompat.java b/core/core/src/main/java/androidx/core/view/inputmethod/InputConnectionCompat.java
index 6bb2927..6023f32 100644
--- a/core/core/src/main/java/androidx/core/view/inputmethod/InputConnectionCompat.java
+++ b/core/core/src/main/java/androidx/core/view/inputmethod/InputConnectionCompat.java
@@ -37,8 +37,6 @@
 import android.view.inputmethod.InputConnectionWrapper;
 import android.view.inputmethod.InputContentInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.util.ObjectsCompat;
 import androidx.core.util.Preconditions;
@@ -46,6 +44,9 @@
 import androidx.core.view.OnReceiveContentListener;
 import androidx.core.view.ViewCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link InputConnection} introduced after API level 13 in a
  * backwards compatible fashion.
@@ -265,8 +266,7 @@
      * {@link ViewCompat#setOnReceiveContentListener} instead.
      */
     @Deprecated
-    @NonNull
-    public static InputConnection createWrapper(@NonNull InputConnection inputConnection,
+    public static @NonNull InputConnection createWrapper(@NonNull InputConnection inputConnection,
             @NonNull EditorInfo editorInfo,
             @NonNull OnCommitContentListener onCommitContentListener) {
         ObjectsCompat.requireNonNull(inputConnection, "inputConnection must be non-null");
@@ -341,8 +341,7 @@
      * @return A wrapper {@link InputConnection} object that can be returned to the IME.
      */
     @SuppressWarnings("deprecation")
-    @NonNull
-    public static InputConnection createWrapper(@NonNull View view,
+    public static @NonNull InputConnection createWrapper(@NonNull View view,
             @NonNull InputConnection inputConnection, @NonNull EditorInfo editorInfo) {
         OnCommitContentListener onCommitContentListener =
                 createOnCommitContentListenerUsingPerformReceiveContent(view);
@@ -354,9 +353,9 @@
      * {@link ViewCompat#performReceiveContent} to insert content. This is useful for widgets
      * that support content insertion using an {@link OnReceiveContentListener}.
      */
-    @NonNull
-    private static OnCommitContentListener createOnCommitContentListenerUsingPerformReceiveContent(
-            @NonNull View view) {
+    private static @NonNull OnCommitContentListener
+            createOnCommitContentListenerUsingPerformReceiveContent(
+                    @NonNull View view) {
         Preconditions.checkNotNull(view);
         return (inputContentInfo, flags, opts) -> {
             Bundle extras = opts;
diff --git a/core/core/src/main/java/androidx/core/view/inputmethod/InputContentInfoCompat.java b/core/core/src/main/java/androidx/core/view/inputmethod/InputContentInfoCompat.java
index ca56608..bddc38e 100644
--- a/core/core/src/main/java/androidx/core/view/inputmethod/InputContentInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/view/inputmethod/InputContentInfoCompat.java
@@ -21,10 +21,11 @@
 import android.os.Build;
 import android.view.inputmethod.InputContentInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in InputContentInfo introduced after API level 13 in a backwards
  * compatible fashion.
@@ -32,17 +33,13 @@
 public final class InputContentInfoCompat {
 
     private interface InputContentInfoCompatImpl {
-        @NonNull
-        Uri getContentUri();
+        @NonNull Uri getContentUri();
 
-        @NonNull
-        ClipDescription getDescription();
+        @NonNull ClipDescription getDescription();
 
-        @Nullable
-        Uri getLinkUri();
+        @Nullable Uri getLinkUri();
 
-        @Nullable
-        Object getInputContentInfo();
+        @Nullable Object getInputContentInfo();
 
         void requestPermission();
 
@@ -51,12 +48,9 @@
 
     private static final class InputContentInfoCompatBaseImpl
             implements InputContentInfoCompatImpl {
-        @NonNull
-        private final Uri mContentUri;
-        @NonNull
-        private final ClipDescription mDescription;
-        @Nullable
-        private final Uri mLinkUri;
+        private final @NonNull Uri mContentUri;
+        private final @NonNull ClipDescription mDescription;
+        private final @Nullable Uri mLinkUri;
 
         InputContentInfoCompatBaseImpl(@NonNull Uri contentUri,
                 @NonNull ClipDescription description, @Nullable Uri linkUri) {
@@ -65,27 +59,23 @@
             mLinkUri = linkUri;
         }
 
-        @NonNull
         @Override
-        public Uri getContentUri() {
+        public @NonNull Uri getContentUri() {
             return mContentUri;
         }
 
-        @NonNull
         @Override
-        public ClipDescription getDescription() {
+        public @NonNull ClipDescription getDescription() {
             return mDescription;
         }
 
-        @Nullable
         @Override
-        public Uri getLinkUri() {
+        public @Nullable Uri getLinkUri() {
             return mLinkUri;
         }
 
-        @Nullable
         @Override
-        public Object getInputContentInfo() {
+        public @Nullable Object getInputContentInfo() {
             return null;
         }
 
@@ -101,8 +91,7 @@
     @RequiresApi(25)
     private static final class InputContentInfoCompatApi25Impl
             implements InputContentInfoCompatImpl {
-        @NonNull
-        final InputContentInfo mObject;
+        final @NonNull InputContentInfo mObject;
 
         InputContentInfoCompatApi25Impl(@NonNull Object inputContentInfo) {
             mObject = (InputContentInfo) inputContentInfo;
@@ -114,26 +103,22 @@
         }
 
         @Override
-        @NonNull
-        public Uri getContentUri() {
+        public @NonNull Uri getContentUri() {
             return mObject.getContentUri();
         }
 
         @Override
-        @NonNull
-        public ClipDescription getDescription() {
+        public @NonNull ClipDescription getDescription() {
             return mObject.getDescription();
         }
 
         @Override
-        @Nullable
-        public Uri getLinkUri() {
+        public @Nullable Uri getLinkUri() {
             return mObject.getLinkUri();
         }
 
         @Override
-        @NonNull
-        public Object getInputContentInfo() {
+        public @NonNull Object getInputContentInfo() {
             return mObject;
         }
 
@@ -179,8 +164,7 @@
     /**
      * @return content URI with which the content can be obtained.
      */
-    @NonNull
-    public Uri getContentUri() {
+    public @NonNull Uri getContentUri() {
         return mImpl.getContentUri();
     }
 
@@ -189,16 +173,14 @@
      * such as MIME type(s). {@link ClipDescription#getLabel()} can be used for accessibility
      * purpose.
      */
-    @NonNull
-    public ClipDescription getDescription() {
+    public @NonNull ClipDescription getDescription() {
         return mImpl.getDescription();
     }
 
     /**
      * @return an optional {@code http} or {@code https} URI that is related to this content.
      */
-    @Nullable
-    public Uri getLinkUri() {
+    public @Nullable Uri getLinkUri() {
         return mImpl.getLinkUri();
     }
 
@@ -212,8 +194,7 @@
      * @return an equivalent {@link InputContentInfoCompat} object, or {@code null} if not
      * supported.
      */
-    @Nullable
-    public static InputContentInfoCompat wrap(@Nullable Object inputContentInfo) {
+    public static @Nullable InputContentInfoCompat wrap(@Nullable Object inputContentInfo) {
         if (inputContentInfo == null) {
             return null;
         }
@@ -231,8 +212,7 @@
      * @return an equivalent android.view.inputmethod.InputContentInfo object, or {@code null} if
      * not supported.
      */
-    @Nullable
-    public Object unwrap() {
+    public @Nullable Object unwrap() {
         return mImpl.getInputContentInfo();
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/insetscontrast/ContrastProtection.java b/core/core/src/main/java/androidx/core/view/insetscontrast/ContrastProtection.java
index 2855690..925e81b 100644
--- a/core/core/src/main/java/androidx/core/view/insetscontrast/ContrastProtection.java
+++ b/core/core/src/main/java/androidx/core/view/insetscontrast/ContrastProtection.java
@@ -22,12 +22,13 @@
 import android.view.animation.PathInterpolator;
 
 import androidx.annotation.FloatRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.graphics.Insets;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.core.view.WindowInsetsCompat.Side.InsetsSide;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An abstract class which describes a layer to be placed on the content of a window and underneath
  * the system bar (which has a transparent background) to ensure the readability of the foreground
@@ -109,8 +110,7 @@
      *
      * @return the attributes this protection is associated with.
      */
-    @NonNull
-    Attributes getAttributes() {
+    @NonNull Attributes getAttributes() {
         return mAttributes;
     }
 
@@ -153,8 +153,7 @@
         return updateLayout();
     }
 
-    @NonNull
-    Insets updateLayout() {
+    @NonNull Insets updateLayout() {
         Insets consumed = Insets.NONE;
         final int inset;
         switch (mSide) {
@@ -416,8 +415,7 @@
          *
          * @return the margin of the protection in pixels.
          */
-        @NonNull
-        Insets getMargin() {
+        @NonNull Insets getMargin() {
             return mMargin;
         }
 
@@ -435,8 +433,7 @@
          *
          * @return the {@link Drawable} that fills the protection.
          */
-        @Nullable
-        Drawable getDrawable() {
+        @Nullable Drawable getDrawable() {
             return mDrawable;
         }
 
diff --git a/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionGroup.java b/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionGroup.java
index 917bf3a..c494694 100644
--- a/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionGroup.java
+++ b/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionGroup.java
@@ -23,9 +23,10 @@
 
 import android.graphics.RectF;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.Insets;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -170,8 +171,7 @@
      * @param index the index of the protection to return.
      * @return the protection at the specified position in this group.
      */
-    @NonNull
-    ContrastProtection getProtection(int index) {
+    @NonNull ContrastProtection getProtection(int index) {
         return mProtections.get(index);
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionView.java b/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionView.java
index 10da02e..b7986b3 100644
--- a/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionView.java
+++ b/core/core/src/main/java/androidx/core/view/insetscontrast/ProtectionView.java
@@ -25,10 +25,11 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.Insets;
 import androidx.core.view.WindowInsetsCompat;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 
 /**
diff --git a/core/core/src/main/java/androidx/core/view/insetscontrast/SystemBarStateMonitor.java b/core/core/src/main/java/androidx/core/view/insetscontrast/SystemBarStateMonitor.java
index 3bda8d0..81d6036 100644
--- a/core/core/src/main/java/androidx/core/view/insetscontrast/SystemBarStateMonitor.java
+++ b/core/core/src/main/java/androidx/core/view/insetscontrast/SystemBarStateMonitor.java
@@ -34,13 +34,14 @@
 import android.view.Window;
 import android.view.WindowInsets;
 
-import androidx.annotation.NonNull;
 import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowCompat;
 import androidx.core.view.WindowInsetsAnimationCompat;
 import androidx.core.view.WindowInsetsCompat;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -134,10 +135,9 @@
                     }
 
                     @Override
-                    @NonNull
-                    public WindowInsetsAnimationCompat.BoundsCompat onStart(
+                    public WindowInsetsAnimationCompat.@NonNull BoundsCompat onStart(
                             @NonNull WindowInsetsAnimationCompat animation,
-                            @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds) {
+                            WindowInsetsAnimationCompat.@NonNull BoundsCompat bounds) {
                         if (!animatesSystemBars(animation)) {
                             return bounds;
                         }
@@ -161,8 +161,8 @@
                     }
 
                     @Override
-                    @NonNull
-                    public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat windowInsets,
+                    public @NonNull WindowInsetsCompat onProgress(
+                            @NonNull WindowInsetsCompat windowInsets,
                             @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
                         final RectF alpha = new RectF(1f, 1f, 1f, 1f);
                         int animatingSides = 0;
diff --git a/core/core/src/main/java/androidx/core/widget/AutoScrollHelper.java b/core/core/src/main/java/androidx/core/widget/AutoScrollHelper.java
index fe2be2a..fee7ffa 100644
--- a/core/core/src/main/java/androidx/core/widget/AutoScrollHelper.java
+++ b/core/core/src/main/java/androidx/core/widget/AutoScrollHelper.java
@@ -26,9 +26,10 @@
 import android.view.animation.AnimationUtils;
 import android.view.animation.Interpolator;
 
-import androidx.annotation.NonNull;
 import androidx.core.view.ViewCompat;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * AutoScrollHelper is a utility class for adding automatic edge-triggered
  * scrolling to Views.
@@ -291,8 +292,7 @@
      *            {@link #NO_MAX} to leave the relative value unconstrained.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setMaximumVelocity(float horizontalMax, float verticalMax) {
+    public @NonNull AutoScrollHelper setMaximumVelocity(float horizontalMax, float verticalMax) {
         mMaximumVelocity[HORIZONTAL] = horizontalMax / 1000f;
         mMaximumVelocity[VERTICAL] = verticalMax / 1000f;
         return this;
@@ -310,8 +310,7 @@
      *            {@link #NO_MIN} to leave the relative value unconstrained.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setMinimumVelocity(float horizontalMin, float verticalMin) {
+    public @NonNull AutoScrollHelper setMinimumVelocity(float horizontalMin, float verticalMin) {
         mMinimumVelocity[HORIZONTAL] = horizontalMin / 1000f;
         mMinimumVelocity[VERTICAL] = verticalMin / 1000f;
         return this;
@@ -332,8 +331,7 @@
      *            ignore.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setRelativeVelocity(float horizontal, float vertical) {
+    public @NonNull AutoScrollHelper setRelativeVelocity(float horizontal, float vertical) {
         mRelativeVelocity[HORIZONTAL] = horizontal / 1000f;
         mRelativeVelocity[VERTICAL] = vertical / 1000f;
         return this;
@@ -354,8 +352,7 @@
      * @param type The type of edge to use.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setEdgeType(int type) {
+    public @NonNull AutoScrollHelper setEdgeType(int type) {
         mEdgeType = type;
         return this;
     }
@@ -374,8 +371,7 @@
      *            maximum value.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setRelativeEdges(float horizontal, float vertical) {
+    public @NonNull AutoScrollHelper setRelativeEdges(float horizontal, float vertical) {
         mRelativeEdges[HORIZONTAL] = horizontal;
         mRelativeEdges[VERTICAL] = vertical;
         return this;
@@ -397,8 +393,7 @@
      *            value.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setMaximumEdges(float horizontalMax, float verticalMax) {
+    public @NonNull AutoScrollHelper setMaximumEdges(float horizontalMax, float verticalMax) {
         mMaximumEdges[HORIZONTAL] = horizontalMax;
         mMaximumEdges[VERTICAL] = verticalMax;
         return this;
@@ -415,8 +410,7 @@
      * @param delayMillis The activation delay in milliseconds.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setActivationDelay(int delayMillis) {
+    public @NonNull AutoScrollHelper setActivationDelay(int delayMillis) {
         mActivationDelay = delayMillis;
         return this;
     }
@@ -431,8 +425,7 @@
      * @param durationMillis The ramp-up duration in milliseconds.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setRampUpDuration(int durationMillis) {
+    public @NonNull AutoScrollHelper setRampUpDuration(int durationMillis) {
         mScroller.setRampUpDuration(durationMillis);
         return this;
     }
@@ -447,8 +440,7 @@
      * @param durationMillis The ramp-down duration in milliseconds.
      * @return The scroll helper, which may used to chain setter calls.
      */
-    @NonNull
-    public AutoScrollHelper setRampDownDuration(int durationMillis) {
+    public @NonNull AutoScrollHelper setRampDownDuration(int durationMillis) {
         mScroller.setRampDownDuration(durationMillis);
         return this;
     }
diff --git a/core/core/src/main/java/androidx/core/widget/AutoSizeableTextView.java b/core/core/src/main/java/androidx/core/widget/AutoSizeableTextView.java
index 1af86ae..90d3d94 100644
--- a/core/core/src/main/java/androidx/core/widget/AutoSizeableTextView.java
+++ b/core/core/src/main/java/androidx/core/widget/AutoSizeableTextView.java
@@ -21,9 +21,10 @@
 import android.os.Build;
 import android.util.TypedValue;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Interface which allows a {@link android.widget.TextView} to receive background auto-sizing calls
  * from {@link TextViewCompat} when running on API v26 devices or lower.
@@ -100,7 +101,7 @@
      * @see #getAutoSizeMaxTextSize()
      * @see #getAutoSizeTextAvailableSizes()
      */
-    void setAutoSizeTextTypeUniformWithPresetSizes(@NonNull int[] presetSizes, int unit)
+    void setAutoSizeTextTypeUniformWithPresetSizes(int @NonNull [] presetSizes, int unit)
             throws IllegalArgumentException;
 
     /**
diff --git a/core/core/src/main/java/androidx/core/widget/CheckedTextViewCompat.java b/core/core/src/main/java/androidx/core/widget/CheckedTextViewCompat.java
index fbdd524..cb8b97e 100644
--- a/core/core/src/main/java/androidx/core/widget/CheckedTextViewCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/CheckedTextViewCompat.java
@@ -23,11 +23,12 @@
 import android.graphics.drawable.Drawable;
 import android.widget.CheckedTextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.graphics.drawable.DrawableCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing {@link CheckedTextView}.
  */
@@ -62,8 +63,7 @@
      *
      * @see #setCheckMarkTintList(CheckedTextView, ColorStateList)
      */
-    @Nullable
-    public static ColorStateList getCheckMarkTintList(@NonNull CheckedTextView textView) {
+    public static @Nullable ColorStateList getCheckMarkTintList(@NonNull CheckedTextView textView) {
         if (SDK_INT >= 21) {
             return Api21Impl.getCheckMarkTintList(textView);
         }
@@ -85,7 +85,7 @@
      * @see DrawableCompat#setTintMode(Drawable, PorterDuff.Mode)
      */
     public static void setCheckMarkTintMode(@NonNull CheckedTextView textView,
-            @Nullable PorterDuff.Mode tintMode) {
+            PorterDuff.@Nullable Mode tintMode) {
         if (SDK_INT >= 21) {
             Api21Impl.setCheckMarkTintMode(textView, tintMode);
         } else if (textView instanceof TintableCheckedTextView) {
@@ -98,8 +98,8 @@
      * @attr name android:checkMarkTintMode
      * @see #setCheckMarkTintMode(CheckedTextView, PorterDuff.Mode)
      */
-    @Nullable
-    public static PorterDuff.Mode getCheckMarkTintMode(@NonNull CheckedTextView textView) {
+    public static PorterDuff.@Nullable Mode getCheckMarkTintMode(
+            @NonNull CheckedTextView textView) {
         if (SDK_INT >= 21) {
             return Api21Impl.getCheckMarkTintMode(textView);
         }
@@ -117,8 +117,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "textView.getCheckMarkDrawable()")
-    @Nullable
-    public static Drawable getCheckMarkDrawable(@NonNull CheckedTextView textView) {
+    public static @Nullable Drawable getCheckMarkDrawable(@NonNull CheckedTextView textView) {
         return textView.getCheckMarkDrawable();
     }
 
@@ -133,18 +132,16 @@
             textView.setCheckMarkTintList(tint);
         }
 
-        @Nullable
-        static ColorStateList getCheckMarkTintList(@NonNull CheckedTextView textView) {
+        static @Nullable ColorStateList getCheckMarkTintList(@NonNull CheckedTextView textView) {
             return textView.getCheckMarkTintList();
         }
 
         static void setCheckMarkTintMode(@NonNull CheckedTextView textView,
-                @Nullable PorterDuff.Mode tintMode) {
+                PorterDuff.@Nullable Mode tintMode) {
             textView.setCheckMarkTintMode(tintMode);
         }
 
-        @Nullable
-        static PorterDuff.Mode getCheckMarkTintMode(@NonNull CheckedTextView textView) {
+        static PorterDuff.@Nullable Mode getCheckMarkTintMode(@NonNull CheckedTextView textView) {
             return textView.getCheckMarkTintMode();
         }
     }
diff --git a/core/core/src/main/java/androidx/core/widget/CompoundButtonCompat.java b/core/core/src/main/java/androidx/core/widget/CompoundButtonCompat.java
index 03f2904..6ac6123 100644
--- a/core/core/src/main/java/androidx/core/widget/CompoundButtonCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/CompoundButtonCompat.java
@@ -23,11 +23,12 @@
 import android.util.Log;
 import android.widget.CompoundButton;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.graphics.drawable.DrawableCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.Field;
 
 /**
@@ -68,8 +69,7 @@
      *
      * @see #setButtonTintList(CompoundButton, ColorStateList)
      */
-    @Nullable
-    public static ColorStateList getButtonTintList(@NonNull CompoundButton button) {
+    public static @Nullable ColorStateList getButtonTintList(@NonNull CompoundButton button) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getButtonTintList(button);
         }
@@ -92,7 +92,7 @@
      * @see DrawableCompat#setTintMode(Drawable, PorterDuff.Mode)
      */
     public static void setButtonTintMode(@NonNull CompoundButton button,
-            @Nullable PorterDuff.Mode tintMode) {
+            PorterDuff.@Nullable Mode tintMode) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.setButtonTintMode(button, tintMode);
         } else if (button instanceof TintableCompoundButton) {
@@ -105,8 +105,7 @@
      * @attr name android:buttonTintMode
      * @see #setButtonTintMode(CompoundButton, PorterDuff.Mode)
      */
-    @Nullable
-    public static PorterDuff.Mode getButtonTintMode(@NonNull CompoundButton button) {
+    public static PorterDuff.@Nullable Mode getButtonTintMode(@NonNull CompoundButton button) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getButtonTintMode(button);
         }
@@ -121,8 +120,7 @@
      *
      * @see CompoundButton#setButtonDrawable(Drawable)
      */
-    @Nullable
-    public static Drawable getButtonDrawable(@NonNull CompoundButton button) {
+    public static @Nullable Drawable getButtonDrawable(@NonNull CompoundButton button) {
         if (Build.VERSION.SDK_INT >= 23) {
             return Api23Impl.getButtonDrawable(button);
         }
diff --git a/core/core/src/main/java/androidx/core/widget/ContentLoadingProgressBar.java b/core/core/src/main/java/androidx/core/widget/ContentLoadingProgressBar.java
index 2eb84ef..8cd2b58 100644
--- a/core/core/src/main/java/androidx/core/widget/ContentLoadingProgressBar.java
+++ b/core/core/src/main/java/androidx/core/widget/ContentLoadingProgressBar.java
@@ -21,10 +21,11 @@
 import android.view.View;
 import android.widget.ProgressBar;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * ContentLoadingProgressBar implements a ProgressBar that waits a minimum time to be
  * dismissed before showing. Once visible, the progress bar will be visible for
diff --git a/core/core/src/main/java/androidx/core/widget/EdgeEffectCompat.java b/core/core/src/main/java/androidx/core/widget/EdgeEffectCompat.java
index 9c4fe05..4392c5d 100644
--- a/core/core/src/main/java/androidx/core/widget/EdgeEffectCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/EdgeEffectCompat.java
@@ -25,10 +25,11 @@
 import android.widget.OverScroller;
 import android.widget.Scroller;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing {@link EdgeEffect}.
  *
@@ -63,8 +64,8 @@
      * @param context Context to use for theming the effect
      * @param attrs The attributes of the XML tag that is inflating the view
      */
-    @NonNull
-    public static EdgeEffect create(@NonNull Context context, @Nullable AttributeSet attrs) {
+    public static @NonNull EdgeEffect create(@NonNull Context context,
+            @Nullable AttributeSet attrs) {
         if (SDK_INT >= 31) {
             return Api31Impl.create(context, attrs);
         }
diff --git a/core/core/src/main/java/androidx/core/widget/ImageViewCompat.java b/core/core/src/main/java/androidx/core/widget/ImageViewCompat.java
index bf73151..9810047 100644
--- a/core/core/src/main/java/androidx/core/widget/ImageViewCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/ImageViewCompat.java
@@ -22,10 +22,11 @@
 import android.os.Build;
 import android.widget.ImageView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for accessing features in {@link ImageView}.
  */
@@ -33,8 +34,7 @@
     /**
      * Return the tint applied to the image drawable, if specified.
      */
-    @Nullable
-    public static ColorStateList getImageTintList(@NonNull ImageView view) {
+    public static @Nullable ColorStateList getImageTintList(@NonNull ImageView view) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getImageTintList(view);
         }
@@ -70,8 +70,7 @@
     /**
      * Return the blending mode used to apply the tint to the image drawable, if specified.
      */
-    @Nullable
-    public static PorterDuff.Mode getImageTintMode(@NonNull ImageView view) {
+    public static PorterDuff.@Nullable Mode getImageTintMode(@NonNull ImageView view) {
         if (Build.VERSION.SDK_INT >= 21) {
             return Api21Impl.getImageTintMode(view);
         }
@@ -85,7 +84,7 @@
      * {@link #setImageTintList(ImageView, ColorStateList)}
      * to the image drawable. The default mode is {@link PorterDuff.Mode#SRC_IN}.
      */
-    public static void setImageTintMode(@NonNull ImageView view, @Nullable PorterDuff.Mode mode) {
+    public static void setImageTintMode(@NonNull ImageView view, PorterDuff.@Nullable Mode mode) {
         if (Build.VERSION.SDK_INT >= 21) {
             Api21Impl.setImageTintMode(view, mode);
 
diff --git a/core/core/src/main/java/androidx/core/widget/ListPopupWindowCompat.java b/core/core/src/main/java/androidx/core/widget/ListPopupWindowCompat.java
index 5818729..97835a0 100644
--- a/core/core/src/main/java/androidx/core/widget/ListPopupWindowCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/ListPopupWindowCompat.java
@@ -21,8 +21,8 @@
 import android.view.View.OnTouchListener;
 import android.widget.ListPopupWindow;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper for accessing features in {@link ListPopupWindow}.
@@ -94,8 +94,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "listPopupWindow.createDragToOpenListener(src)")
-    @Nullable
-    public static OnTouchListener createDragToOpenListener(
+    public static @Nullable OnTouchListener createDragToOpenListener(
             @NonNull ListPopupWindow listPopupWindow, @NonNull View src) {
         return listPopupWindow.createDragToOpenListener(src);
     }
diff --git a/core/core/src/main/java/androidx/core/widget/ListViewAutoScrollHelper.java b/core/core/src/main/java/androidx/core/widget/ListViewAutoScrollHelper.java
index c4bc6b6..f068f74 100644
--- a/core/core/src/main/java/androidx/core/widget/ListViewAutoScrollHelper.java
+++ b/core/core/src/main/java/androidx/core/widget/ListViewAutoScrollHelper.java
@@ -19,7 +19,7 @@
 import android.view.View;
 import android.widget.ListView;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * An implementation of {@link AutoScrollHelper} that knows how to scroll
diff --git a/core/core/src/main/java/androidx/core/widget/ListViewCompat.java b/core/core/src/main/java/androidx/core/widget/ListViewCompat.java
index 328fcb68a..cd5feb2 100644
--- a/core/core/src/main/java/androidx/core/widget/ListViewCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/ListViewCompat.java
@@ -18,7 +18,7 @@
 
 import android.widget.ListView;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Helper for accessing features in {@link ListView}.
diff --git a/core/core/src/main/java/androidx/core/widget/NestedScrollView.java b/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
index f7f56af..5e010d2 100644
--- a/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
+++ b/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
@@ -48,8 +48,6 @@
 import android.widget.OverScroller;
 import android.widget.ScrollView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
@@ -68,6 +66,9 @@
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
 import androidx.core.view.accessibility.AccessibilityRecordCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -128,17 +129,14 @@
 
     @RestrictTo(LIBRARY)
     @VisibleForTesting
-    @NonNull
-    public EdgeEffect mEdgeGlowTop;
+    public @NonNull EdgeEffect mEdgeGlowTop;
 
     @RestrictTo(LIBRARY)
     @VisibleForTesting
-    @NonNull
-    public EdgeEffect mEdgeGlowBottom;
+    public @NonNull EdgeEffect mEdgeGlowBottom;
 
     @VisibleForTesting
-    @Nullable
-    ScrollFeedbackProviderCompat mScrollFeedbackProvider;
+    @Nullable ScrollFeedbackProviderCompat mScrollFeedbackProvider;
 
     /**
      * Position of the last motion event; only used with touch related events (usually to assist
@@ -282,7 +280,7 @@
 
     @Override
     public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @Nullable int[] offsetInWindow, int type, @NonNull int[] consumed) {
+            int dyUnconsumed, int @Nullable [] offsetInWindow, int type, int @NonNull [] consumed) {
         mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow, type, consumed);
     }
@@ -306,7 +304,7 @@
 
     @Override
     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+            int dyUnconsumed, int @Nullable [] offsetInWindow, int type) {
         return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow, type);
     }
@@ -315,8 +313,8 @@
     public boolean dispatchNestedPreScroll(
             int dx,
             int dy,
-            @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow,
+            int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow,
             int type
     ) {
         return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
@@ -351,14 +349,14 @@
 
     @Override
     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
-            int dyUnconsumed, @Nullable int[] offsetInWindow) {
+            int dyUnconsumed, int @Nullable [] offsetInWindow) {
         return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                 offsetInWindow);
     }
 
     @Override
-    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
-            @Nullable int[] offsetInWindow) {
+    public boolean dispatchNestedPreScroll(int dx, int dy, int @Nullable [] consumed,
+            int @Nullable [] offsetInWindow) {
         return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH);
     }
 
@@ -376,11 +374,11 @@
 
     @Override
     public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
-            int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
+            int dxUnconsumed, int dyUnconsumed, int type, int @NonNull [] consumed) {
         onNestedScrollInternal(dyUnconsumed, type, consumed);
     }
 
-    private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
+    private void onNestedScrollInternal(int dyUnconsumed, int type, int @Nullable [] consumed) {
         final int oldScrollY = getScrollY();
         scrollBy(0, dyUnconsumed);
         final int myConsumed = getScrollY() - oldScrollY;
@@ -421,7 +419,7 @@
     }
 
     @Override
-    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
+    public void onNestedPreScroll(@NonNull View target, int dx, int dy, int @NonNull [] consumed,
             int type) {
         dispatchNestedPreScroll(dx, dy, consumed, null, type);
     }
@@ -452,7 +450,7 @@
     }
 
     @Override
-    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
+    public void onNestedPreScroll(@NonNull View target, int dx, int dy, int @NonNull [] consumed) {
         onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH);
     }
 
@@ -2502,9 +2500,8 @@
         requestLayout();
     }
 
-    @NonNull
     @Override
-    protected Parcelable onSaveInstanceState() {
+    protected @NonNull Parcelable onSaveInstanceState() {
         Parcelable superState = super.onSaveInstanceState();
         SavedState ss = new SavedState(superState);
         ss.scrollPosition = getScrollY();
@@ -2529,9 +2526,8 @@
             dest.writeInt(scrollPosition);
         }
 
-        @NonNull
         @Override
-        public String toString() {
+        public @NonNull String toString() {
             return "HorizontalScrollView.SavedState{"
                     + Integer.toHexString(System.identityHashCode(this))
                     + " scrollPosition=" + scrollPosition + "}";
diff --git a/core/core/src/main/java/androidx/core/widget/PopupMenuCompat.java b/core/core/src/main/java/androidx/core/widget/PopupMenuCompat.java
index 01a7071..ed6deb9 100644
--- a/core/core/src/main/java/androidx/core/widget/PopupMenuCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/PopupMenuCompat.java
@@ -20,8 +20,8 @@
 import android.view.View.OnTouchListener;
 import android.widget.PopupMenu;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper for accessing features in {@link PopupMenu}.
@@ -50,8 +50,7 @@
      * @return a touch listener that controls drag-to-open behavior, or {@code null} on
      *         unsupported APIs
      */
-    @Nullable
-    public static OnTouchListener getDragToOpenListener(@NonNull Object popupMenu) {
+    public static @Nullable OnTouchListener getDragToOpenListener(@NonNull Object popupMenu) {
         return ((PopupMenu) popupMenu).getDragToOpenListener();
     }
 }
diff --git a/core/core/src/main/java/androidx/core/widget/PopupWindowCompat.java b/core/core/src/main/java/androidx/core/widget/PopupWindowCompat.java
index f23c596..580753c 100644
--- a/core/core/src/main/java/androidx/core/widget/PopupWindowCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/PopupWindowCompat.java
@@ -21,9 +21,10 @@
 import android.view.View;
 import android.widget.PopupWindow;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 
diff --git a/core/core/src/main/java/androidx/core/widget/TextViewCompat.java b/core/core/src/main/java/androidx/core/widget/TextViewCompat.java
index 1bdbdd9..ea26466 100644
--- a/core/core/src/main/java/androidx/core/widget/TextViewCompat.java
+++ b/core/core/src/main/java/androidx/core/widget/TextViewCompat.java
@@ -55,8 +55,6 @@
 import androidx.annotation.FloatRange;
 import androidx.annotation.IntDef;
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.Px;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
@@ -64,6 +62,9 @@
 import androidx.core.text.PrecomputedTextCompat;
 import androidx.core.util.Preconditions;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.reflect.InvocationTargetException;
@@ -72,7 +73,6 @@
 import java.util.List;
 import java.util.Locale;
 
-
 /**
  * Helper for accessing features in {@link TextView}.
  */
@@ -225,8 +225,7 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "textView.getCompoundDrawablesRelative()")
-    @NonNull
-    public static Drawable[] getCompoundDrawablesRelative(@NonNull TextView textView) {
+    public static Drawable @NonNull [] getCompoundDrawablesRelative(@NonNull TextView textView) {
         return textView.getCompoundDrawablesRelative();
     }
 
@@ -306,7 +305,7 @@
      */
     @SuppressWarnings("RedundantCast") // Intentionally invoking interface method.
     public static void setAutoSizeTextTypeUniformWithPresetSizes(@NonNull TextView textView,
-            @NonNull int[] presetSizes, int unit) throws IllegalArgumentException {
+            int @NonNull [] presetSizes, int unit) throws IllegalArgumentException {
         if (Build.VERSION.SDK_INT >= 27) {
             Api26Impl.setAutoSizeTextTypeUniformWithPresetSizes(textView, presetSizes, unit);
         } else if (textView instanceof AutoSizeableTextView) {
@@ -390,9 +389,8 @@
      *
      * @attr name android:autoSizePresetSizes
      */
-    @NonNull
     @SuppressWarnings("RedundantCast") // Intentionally invoking interface method.
-    public static int[] getAutoSizeTextAvailableSizes(@NonNull TextView textView) {
+    public static int @NonNull [] getAutoSizeTextAvailableSizes(@NonNull TextView textView) {
         if (Build.VERSION.SDK_INT >= 27) {
             return Api26Impl.getAutoSizeTextAvailableSizes(textView);
         }
@@ -427,8 +425,8 @@
      */
     @Deprecated
     @androidx.annotation.ReplaceWith(expression = "textView.setCustomSelectionActionModeCallback(callback)")
-    public static void setCustomSelectionActionModeCallback(@NonNull final TextView textView,
-                @NonNull final ActionMode.Callback callback) {
+    public static void setCustomSelectionActionModeCallback(final @NonNull TextView textView,
+                final ActionMode.@NonNull Callback callback) {
         textView.setCustomSelectionActionModeCallback(
                 wrapCustomSelectionActionModeCallback(textView, callback));
     }
@@ -437,10 +435,9 @@
      * @see #setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public static ActionMode.Callback wrapCustomSelectionActionModeCallback(
-            @NonNull final TextView textView,
-            @Nullable final ActionMode.Callback callback) {
+    public static ActionMode.@Nullable Callback wrapCustomSelectionActionModeCallback(
+            final @NonNull TextView textView,
+            final ActionMode.@Nullable Callback callback) {
         if (Build.VERSION.SDK_INT < 26 || Build.VERSION.SDK_INT > 27
                 || callback instanceof OreoCallback || callback == null) {
             // If the bug does not affect the current SDK version, or if
@@ -459,9 +456,8 @@
      * @see #setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
-    @Nullable
-    public static ActionMode.Callback unwrapCustomSelectionActionModeCallback(
-            @Nullable ActionMode.Callback callback) {
+    public static ActionMode.@Nullable Callback unwrapCustomSelectionActionModeCallback(
+            ActionMode.@Nullable Callback callback) {
         if (callback instanceof OreoCallback && Build.VERSION.SDK_INT >= 26) {
             return ((OreoCallback) callback).getWrappedCallback();
         }
@@ -512,8 +508,7 @@
             mCallback.onDestroyActionMode(mode);
         }
 
-        @NonNull
-        ActionMode.Callback getWrappedCallback() {
+        ActionMode.@NonNull Callback getWrappedCallback() {
             return mCallback;
         }
 
@@ -634,7 +629,7 @@
      * @attr name android:firstBaselineToTopHeight
      */
     public static void setFirstBaselineToTopHeight(
-            @NonNull final TextView textView,
+            final @NonNull TextView textView,
             @Px @IntRange(from = 0) final int firstBaselineToTopHeight) {
         Preconditions.checkArgumentNonnegative(firstBaselineToTopHeight);
         if (Build.VERSION.SDK_INT >= 28) {
@@ -677,7 +672,7 @@
      * @attr name android:lastBaselineToBottomHeight
      */
     public static void setLastBaselineToBottomHeight(
-            @NonNull final TextView textView,
+            final @NonNull TextView textView,
             @Px @IntRange(from = 0) int lastBaselineToBottomHeight) {
         Preconditions.checkArgumentNonnegative(lastBaselineToBottomHeight);
 
@@ -705,7 +700,7 @@
      * @see #setFirstBaselineToTopHeight(TextView, int)
      * @attr name android:firstBaselineToTopHeight
      */
-    public static int getFirstBaselineToTopHeight(@NonNull final TextView textView) {
+    public static int getFirstBaselineToTopHeight(final @NonNull TextView textView) {
         return textView.getPaddingTop() - textView.getPaint().getFontMetricsInt().top;
     }
 
@@ -715,7 +710,7 @@
      * @see #setLastBaselineToBottomHeight(TextView, int)
      * @attr name android:lastBaselineToBottomHeight
      */
-    public static int getLastBaselineToBottomHeight(@NonNull final TextView textView) {
+    public static int getLastBaselineToBottomHeight(final @NonNull TextView textView) {
         return textView.getPaddingBottom() + textView.getPaint().getFontMetricsInt().bottom;
     }
 
@@ -733,7 +728,7 @@
      *
      * @attr name android:lineHeight
      */
-    public static void setLineHeight(@NonNull final TextView textView,
+    public static void setLineHeight(final @NonNull TextView textView,
                               @Px @IntRange(from = 0) int lineHeight) {
         Preconditions.checkArgumentNonnegative(lineHeight);
 
@@ -784,8 +779,8 @@
      * @return a current {@link PrecomputedTextCompat.Params}
      * @see PrecomputedTextCompat
      */
-    public static @NonNull PrecomputedTextCompat.Params getTextMetricsParams(
-            @NonNull final TextView textView) {
+    public static PrecomputedTextCompat.@NonNull Params getTextMetricsParams(
+            final @NonNull TextView textView) {
         if (Build.VERSION.SDK_INT >= 28) {
             return new PrecomputedTextCompat.Params(Api28Impl.getTextMetricsParams(textView));
         } else {
@@ -807,7 +802,7 @@
      * @see PrecomputedTextCompat
      */
     public static void setTextMetricsParams(@NonNull TextView textView,
-            @NonNull PrecomputedTextCompat.Params params) {
+            PrecomputedTextCompat.@NonNull Params params) {
 
         // There is no way of setting text direction heuristics to TextView.
         // Convert to the View's text direction int values.
@@ -928,7 +923,7 @@
     /**
      * Convert TextDirectionHeuristic to TextDirection int values
      */
-    private static int getTextDirection(@NonNull  TextDirectionHeuristic heuristic) {
+    private static int getTextDirection(@NonNull TextDirectionHeuristic heuristic) {
         if (heuristic == TextDirectionHeuristics.FIRSTSTRONG_RTL) {
             return TEXT_DIRECTION_FIRST_STRONG;
         } else if (heuristic == TextDirectionHeuristics.FIRSTSTRONG_LTR) {
@@ -973,8 +968,7 @@
      * Only returns meaningful info when running on API v24 or newer, or if {@code textView}
      * implements the {@code TintableCompoundDrawablesView} interface.
      */
-    @Nullable
-    public static ColorStateList getCompoundDrawableTintList(@NonNull TextView textView) {
+    public static @Nullable ColorStateList getCompoundDrawableTintList(@NonNull TextView textView) {
         Preconditions.checkNotNull(textView);
         if (Build.VERSION.SDK_INT >= 24) {
             return Api23Impl.getCompoundDrawableTintList(textView);
@@ -992,7 +986,7 @@
      * {@code TintableCompoundDrawablesView} interface.
      */
     public static void setCompoundDrawableTintMode(@NonNull TextView textView,
-            @Nullable PorterDuff.Mode tintMode) {
+            PorterDuff.@Nullable Mode tintMode) {
         Preconditions.checkNotNull(textView);
         if (Build.VERSION.SDK_INT >= 24) {
             Api23Impl.setCompoundDrawableTintMode(textView, tintMode);
@@ -1008,8 +1002,8 @@
      * Only returns meaningful info when running on API v24 or newer, or if {@code textView}
      * implements the {@code TintableCompoundDrawablesView} interface.
      */
-    @Nullable
-    public static PorterDuff.Mode getCompoundDrawableTintMode(@NonNull TextView textView) {
+    public static PorterDuff.@Nullable Mode getCompoundDrawableTintMode(
+            @NonNull TextView textView) {
         Preconditions.checkNotNull(textView);
         if (Build.VERSION.SDK_INT >= 24) {
             return Api23Impl.getCompoundDrawableTintMode(textView);
diff --git a/core/core/src/main/java/androidx/core/widget/TextViewOnReceiveContentListener.java b/core/core/src/main/java/androidx/core/widget/TextViewOnReceiveContentListener.java
index 988b94e..2d82786 100644
--- a/core/core/src/main/java/androidx/core/widget/TextViewOnReceiveContentListener.java
+++ b/core/core/src/main/java/androidx/core/widget/TextViewOnReceiveContentListener.java
@@ -29,14 +29,15 @@
 import android.view.View;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.view.ContentInfoCompat;
 import androidx.core.view.ContentInfoCompat.Flags;
 import androidx.core.view.ContentInfoCompat.Source;
 import androidx.core.view.OnReceiveContentListener;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Default implementation inserting content into editable {@link TextView} components. This class
  * handles insertion of text (plain text, styled text, HTML, etc) but not images or other content.
@@ -46,9 +47,8 @@
 public final class TextViewOnReceiveContentListener implements OnReceiveContentListener {
     private static final String LOG_TAG = "ReceiveContent";
 
-    @Nullable
     @Override
-    public ContentInfoCompat onReceiveContent(@NonNull View view,
+    public @Nullable ContentInfoCompat onReceiveContent(@NonNull View view,
             @NonNull ContentInfoCompat payload) {
         if (Log.isLoggable(LOG_TAG, Log.DEBUG)) {
             Log.d(LOG_TAG, "onReceive: " + payload);
@@ -86,7 +86,7 @@
         return null;
     }
 
-    private static CharSequence coerceToText(@NonNull Context context, @NonNull ClipData.Item item,
+    private static CharSequence coerceToText(@NonNull Context context, ClipData.@NonNull Item item,
             @Flags int flags) {
         if ((flags & FLAG_CONVERT_TO_PLAIN_TEXT) != 0) {
             CharSequence text = item.coerceToText(context);
diff --git a/core/core/src/main/java/androidx/core/widget/TintableCheckedTextView.java b/core/core/src/main/java/androidx/core/widget/TintableCheckedTextView.java
index 83c0c60..7d320fb 100644
--- a/core/core/src/main/java/androidx/core/widget/TintableCheckedTextView.java
+++ b/core/core/src/main/java/androidx/core/widget/TintableCheckedTextView.java
@@ -22,9 +22,10 @@
 import android.graphics.PorterDuff;
 import android.graphics.drawable.Drawable;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Interface which allows a {@link android.widget.CheckedTextView} to receive tinting
  * calls from {@code CheckedTextViewCompat} when running on API v20 devices or lower.
@@ -56,8 +57,7 @@
      *
      * @see #setSupportCheckMarkTintList(ColorStateList)
      */
-    @Nullable
-    ColorStateList getSupportCheckMarkTintList();
+    @Nullable ColorStateList getSupportCheckMarkTintList();
 
     /**
      * Specifies the blending mode which should be used to apply the tint specified by
@@ -71,13 +71,12 @@
      * @see androidx.core.graphics.drawable.DrawableCompat#setTintMode(Drawable,
      * PorterDuff.Mode)
      */
-    void setSupportCheckMarkTintMode(@Nullable PorterDuff.Mode tintMode);
+    void setSupportCheckMarkTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Returns the blending mode used to apply the tint to the check mark drawable
      *
      * @see #setSupportCheckMarkTintMode(PorterDuff.Mode)
      */
-    @Nullable
-    PorterDuff.Mode getSupportCheckMarkTintMode();
+    PorterDuff.@Nullable Mode getSupportCheckMarkTintMode();
 }
diff --git a/core/core/src/main/java/androidx/core/widget/TintableCompoundButton.java b/core/core/src/main/java/androidx/core/widget/TintableCompoundButton.java
index 2de5353..805a07c 100644
--- a/core/core/src/main/java/androidx/core/widget/TintableCompoundButton.java
+++ b/core/core/src/main/java/androidx/core/widget/TintableCompoundButton.java
@@ -20,7 +20,7 @@
 import android.graphics.PorterDuff;
 import android.graphics.drawable.Drawable;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface which allows a {@link android.widget.CompoundButton} to receive tinting
@@ -50,8 +50,7 @@
      *
      * @see #setSupportButtonTintList(ColorStateList)
      */
-    @Nullable
-    ColorStateList getSupportButtonTintList();
+    @Nullable ColorStateList getSupportButtonTintList();
 
     /**
      * Specifies the blending mode which should be used to apply the tint specified by
@@ -65,13 +64,12 @@
      * @see androidx.core.graphics.drawable.DrawableCompat#setTintMode(Drawable,
      * PorterDuff.Mode)
      */
-    void setSupportButtonTintMode(@Nullable PorterDuff.Mode tintMode);
+    void setSupportButtonTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Returns the blending mode used to apply the tint to the button drawable
      *
      * @see #setSupportButtonTintMode(PorterDuff.Mode)
      */
-    @Nullable
-    PorterDuff.Mode getSupportButtonTintMode();
+    PorterDuff.@Nullable Mode getSupportButtonTintMode();
 }
diff --git a/core/core/src/main/java/androidx/core/widget/TintableCompoundDrawablesView.java b/core/core/src/main/java/androidx/core/widget/TintableCompoundDrawablesView.java
index 5afe7a4..f11c4ff 100644
--- a/core/core/src/main/java/androidx/core/widget/TintableCompoundDrawablesView.java
+++ b/core/core/src/main/java/androidx/core/widget/TintableCompoundDrawablesView.java
@@ -19,7 +19,7 @@
 import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface which allows {@link android.widget.TextView} and subclasses to tint compound drawables
@@ -50,8 +50,7 @@
      *
      * @return the tint applied to the compound drawables
      */
-    @Nullable
-    ColorStateList getSupportCompoundDrawablesTintList();
+    @Nullable ColorStateList getSupportCompoundDrawablesTintList();
 
     /**
      * Specifies the blending mode used to apply the tint specified by
@@ -63,7 +62,7 @@
      * @see #getSupportCompoundDrawablesTintMode()
      * @see #setSupportCompoundDrawablesTintList(ColorStateList)
      */
-    void setSupportCompoundDrawablesTintMode(@Nullable PorterDuff.Mode tintMode);
+    void setSupportCompoundDrawablesTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Returns the blending mode used to apply the tint to the compound
@@ -73,6 +72,5 @@
      * drawables
      * @see #setSupportCompoundDrawablesTintMode(PorterDuff.Mode)
      */
-    @Nullable
-    PorterDuff.Mode getSupportCompoundDrawablesTintMode();
+    PorterDuff.@Nullable Mode getSupportCompoundDrawablesTintMode();
 }
diff --git a/core/core/src/main/java/androidx/core/widget/TintableImageSourceView.java b/core/core/src/main/java/androidx/core/widget/TintableImageSourceView.java
index abda77b..5e68657 100644
--- a/core/core/src/main/java/androidx/core/widget/TintableImageSourceView.java
+++ b/core/core/src/main/java/androidx/core/widget/TintableImageSourceView.java
@@ -21,9 +21,10 @@
 import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Interface which allows an {@link android.widget.ImageView} to receive image tinting calls
  * from {@link ImageViewCompat} when running on API v20 devices or lower.
@@ -55,8 +56,7 @@
      *
      * @return the tint applied to the image drawable
      */
-    @Nullable
-    ColorStateList getSupportImageTintList();
+    @Nullable ColorStateList getSupportImageTintList();
 
     /**
      * Specifies the blending mode used to apply the tint specified by
@@ -67,7 +67,7 @@
      *                 {@code null} to clear tint
      * @see #getSupportImageTintMode()
      */
-    void setSupportImageTintMode(@Nullable PorterDuff.Mode tintMode);
+    void setSupportImageTintMode(PorterDuff.@Nullable Mode tintMode);
 
     /**
      * Return the blending mode used to apply the tint to the image
@@ -75,6 +75,5 @@
      *
      * @return the blending mode used to apply the tint to the image drawable
      */
-    @Nullable
-    PorterDuff.Mode getSupportImageTintMode();
+    PorterDuff.@Nullable Mode getSupportImageTintMode();
 }
diff --git a/credentials/credentials-fido/build.gradle b/credentials/credentials-fido/build.gradle
index e8f1534..5a906b0 100644
--- a/credentials/credentials-fido/build.gradle
+++ b/credentials/credentials-fido/build.gradle
@@ -31,7 +31,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:credentials")
+    api(project(":credentials:credentials"))
 
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.testExtJunit)
diff --git a/credentials/credentials-play-services-auth/build.gradle b/credentials/credentials-play-services-auth/build.gradle
index 9fd1bde..ab812ce 100644
--- a/credentials/credentials-play-services-auth/build.gradle
+++ b/credentials/credentials-play-services-auth/build.gradle
@@ -33,7 +33,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:credentials")
+    api(project(":credentials:credentials"))
 
     implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0"){
         exclude group: "androidx.credentials"
diff --git a/credentials/credentials-provider/build.gradle b/credentials/credentials-provider/build.gradle
deleted file mode 100644
index 0711936..0000000
--- a/credentials/credentials-provider/build.gradle
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright (C) 2022 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.
- */
-
-/**
- * This file was created using the `create_project.py` script located in the
- * `<AndroidX root>/development/project-creator` directory.
- *
- * Please use that script when creating a new project, rather than copying an existing project and
- * modifying its settings.
- */
-import androidx.build.Publish
-
-plugins {
-    id("AndroidXPlugin")
-    id("com.android.library")
-    id("org.jetbrains.kotlin.android")
-}
-
-dependencies {
-    api(libs.kotlinStdlib)
-    // Add dependencies here
-}
-
-android {
-    namespace = "androidx.credentials.provider"
-
-    defaultConfig {
-    }
-}
-
-androidx {
-    name = "Credentials Provider"
-    publish = Publish.NONE
-    inceptionYear = "2022"
-    description = "use utility APIs to process requests from, and return responses to the android" +
-            "Credential Manager"
-}
diff --git a/credentials/credentials-provider/src/main/androidx/credentials/androidx-credentials-credentials-provider-documentation.md b/credentials/credentials-provider/src/main/androidx/credentials/androidx-credentials-credentials-provider-documentation.md
deleted file mode 100644
index d3a5d02..0000000
--- a/credentials/credentials-provider/src/main/androidx/credentials/androidx-credentials-credentials-provider-documentation.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Module root
-
-CREDENTIALS CREDENTIALS PROVIDER
-
-# Package androidx.credentials.provider
-
-This package provides utility APIs for providers to use as they respond to credential requests 
-from the android Credential Manager.
\ No newline at end of file
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/provider/PendingIntentHandlerApi34Test.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/provider/PendingIntentHandlerApi34Test.kt
index 59acd5d..a6ff959 100644
--- a/credentials/credentials/src/androidTest/java/androidx/credentials/provider/PendingIntentHandlerApi34Test.kt
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/provider/PendingIntentHandlerApi34Test.kt
@@ -18,11 +18,13 @@
 import android.content.Intent
 import android.content.pm.SigningInfo
 import android.credentials.CredentialOption
+import android.os.Binder
 import android.os.Bundle
 import android.service.credentials.CallingAppInfo
 import android.service.credentials.CreateCredentialRequest
 import android.service.credentials.GetCredentialRequest
 import androidx.annotation.RequiresApi
+import androidx.credentials.CreateCustomCredentialResponse
 import androidx.credentials.CreatePasswordResponse
 import androidx.credentials.GetCredentialResponse
 import androidx.credentials.PasswordCredential
@@ -30,6 +32,9 @@
 import androidx.credentials.equals
 import androidx.credentials.exceptions.CreateCredentialInterruptedException
 import androidx.credentials.exceptions.GetCredentialInterruptedException
+import androidx.credentials.exceptions.domerrors.ConstraintError
+import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
+import androidx.credentials.provider.PendingIntentHandler.Companion.setCreateCredentialResponse
 import androidx.credentials.setUpCreatePasswordRequest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
@@ -280,23 +285,38 @@
     }
 
     @Test
-    fun test_createCredentialException() {
+    fun createCredentialException_success() {
         val intent = Intent()
-        val initialException = CreateCredentialInterruptedException("message")
+        val expected = CreateCredentialInterruptedException("message")
 
-        PendingIntentHandler.setCreateCredentialException(intent, initialException)
+        PendingIntentHandler.setCreateCredentialException(intent, expected)
 
-        val finalException = intent.getCreateCredentialException()
-        assertThat(finalException).isNotNull()
-        assertThat(finalException!!.type).isEqualTo(initialException.type)
-        assertThat(finalException.message).isEqualTo(initialException.message)
+        val actual = PendingIntentHandler.retrieveCreateCredentialException(intent)!!
+        assertThat(actual).isInstanceOf(expected::class.java)
+        assertThat(actual.type).isEqualTo(expected.type)
+        assertThat(actual.errorMessage).isEqualTo(expected.errorMessage)
     }
 
     @Test
-    fun test_createCredentialException_nullExceptionWhenEmptyIntent() {
+    fun createCredentialException_domException_success() {
+        val intent = Intent()
+        val expected = CreatePublicKeyCredentialDomException(ConstraintError(), "Error msg")
+
+        PendingIntentHandler.setCreateCredentialException(intent, expected)
+
+        val actual = PendingIntentHandler.retrieveCreateCredentialException(intent)!!
+        assertThat(actual).isInstanceOf(expected::class.java)
+        assertThat(actual.type).isEqualTo(expected.type)
+        assertThat(actual.errorMessage).isEqualTo(expected.errorMessage)
+        val actualConverted = actual as CreatePublicKeyCredentialDomException
+        assertThat(actualConverted.domError).isInstanceOf((expected.domError)::class.java)
+    }
+
+    @Test
+    fun createCredentialException_emptyIntent_returnsNull() {
         val intent = Intent()
 
-        assertThat(intent.getCreateCredentialException()).isNull()
+        assertThat(PendingIntentHandler.retrieveCreateCredentialException(intent)).isNull()
     }
 
     @Test
@@ -472,21 +492,34 @@
     }
 
     @Test
-    fun test_createCredentialCredentialResponse() {
+    fun createCredentialCredentialResponse_passwordResponse_success() {
         val intent = Intent()
-        val initialResponse = CreatePasswordResponse()
+        val expected = CreatePasswordResponse()
 
-        PendingIntentHandler.setCreateCredentialResponse(intent, initialResponse)
+        PendingIntentHandler.setCreateCredentialResponse(intent, expected)
 
-        val finalResponse = intent.getCreateCredentialCredentialResponse()
-        assertThat(finalResponse).isNotNull()
-        assertThat(equals(finalResponse!!.data, initialResponse.data))
+        val actual = PendingIntentHandler.retrieveCreateCredentialResponse(expected.type, intent)!!
+        assertEquals(actual, expected)
     }
 
     @Test
-    fun test_createCredentialCredentialResponse_nullWhenEmptyIntent() {
+    fun setCreateCredentialResponse_customResponse_success() {
         val intent = Intent()
+        val customData = Bundle()
+        customData.putString("k1", "text")
+        customData.putBinder("k2", Binder())
+        val expected = CreateCustomCredentialResponse("type", customData)
 
-        assertThat(intent.getCreateCredentialCredentialResponse()).isNull()
+        setCreateCredentialResponse(intent, expected)
+
+        val actual = PendingIntentHandler.retrieveCreateCredentialResponse(expected.type, intent)!!
+        assertEquals(actual, expected)
+    }
+
+    @Test
+    fun retrieveCreateCredentialResponse_emptyResponse_returnsNull() {
+        val actual = PendingIntentHandler.retrieveCreateCredentialResponse("type", Intent())
+
+        assertThat(actual).isNull()
     }
 }
diff --git a/credentials/credentials/src/main/java/androidx/credentials/provider/IntentHandlerConverters.kt b/credentials/credentials/src/main/java/androidx/credentials/provider/IntentHandlerConverters.kt
index 6b3e519..f2f4d5b 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/provider/IntentHandlerConverters.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/provider/IntentHandlerConverters.kt
@@ -15,11 +15,9 @@
  */
 
 @file:JvmName("IntentHandlerConverters")
-@file:SuppressLint("ClassVerificationFailure")
 
 package androidx.credentials.provider
 
-import android.annotation.SuppressLint
 import android.content.Intent
 import android.service.credentials.CredentialProviderService
 import androidx.annotation.RequiresApi
diff --git a/credentials/registry/registry-digitalcredentials-mdoc/build.gradle b/credentials/registry/registry-digitalcredentials-mdoc/build.gradle
index 9ca8052..45eb760 100644
--- a/credentials/registry/registry-digitalcredentials-mdoc/build.gradle
+++ b/credentials/registry/registry-digitalcredentials-mdoc/build.gradle
@@ -31,7 +31,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:registry:registry-provider")
+    api(project(":credentials:registry:registry-provider"))
 
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.testExtJunit)
diff --git a/credentials/registry/registry-digitalcredentials-preview/build.gradle b/credentials/registry/registry-digitalcredentials-preview/build.gradle
index 4381874..6ff687e 100644
--- a/credentials/registry/registry-digitalcredentials-preview/build.gradle
+++ b/credentials/registry/registry-digitalcredentials-preview/build.gradle
@@ -31,8 +31,8 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:registry:registry-provider")
-    api project(":credentials:registry:registry-digitalcredentials-mdoc")
+    api(project(":credentials:registry:registry-provider"))
+    api(project(":credentials:registry:registry-digitalcredentials-mdoc"))
 
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.testExtJunit)
diff --git a/credentials/registry/registry-provider-play-services/build.gradle b/credentials/registry/registry-provider-play-services/build.gradle
index 4e5a0f4..e284a25 100644
--- a/credentials/registry/registry-provider-play-services/build.gradle
+++ b/credentials/registry/registry-provider-play-services/build.gradle
@@ -33,7 +33,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:registry:registry-provider")
+    api(project(":credentials:registry:registry-provider"))
 
     implementation(libs.playServicesIdentityCredentials){
         exclude group: "androidx.loader"
diff --git a/credentials/registry/registry-provider/build.gradle b/credentials/registry/registry-provider/build.gradle
index fcae23a..2cb6006 100644
--- a/credentials/registry/registry-provider/build.gradle
+++ b/credentials/registry/registry-provider/build.gradle
@@ -31,7 +31,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    api project(":credentials:credentials")
+    api(project(":credentials:credentials"))
     implementation(libs.kotlinCoroutinesCore)
 
     androidTestImplementation(libs.junit)
diff --git a/datastore/datastore-preferences-rxjava2/src/main/java/androidx/datastore/preferences/rxjava2/RxPreferenceDataStoreDelegate.kt b/datastore/datastore-preferences-rxjava2/src/main/java/androidx/datastore/preferences/rxjava2/RxPreferenceDataStoreDelegate.kt
index 1863a60..fd4a661 100644
--- a/datastore/datastore-preferences-rxjava2/src/main/java/androidx/datastore/preferences/rxjava2/RxPreferenceDataStoreDelegate.kt
+++ b/datastore/datastore-preferences-rxjava2/src/main/java/androidx/datastore/preferences/rxjava2/RxPreferenceDataStoreDelegate.kt
@@ -95,7 +95,7 @@
                     INSTANCE =
                         with(RxPreferenceDataStoreBuilder(applicationContext, fileName)) {
                             setIoScheduler(scheduler)
-                            @Suppress("NewApi", "ClassVerificationFailure") // b/187418647
+                            @Suppress("NewApi") // b/187418647
                             produceMigrations(applicationContext).forEach { addDataMigration(it) }
                             corruptionHandler?.let { setCorruptionHandler(it) }
                             build()
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index cadecaa..eb367a7 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -356,4 +356,8 @@
 # Warning emitted on empty Java files generated from AIDL:
 # https://github.com/kythe/kythe/issues/6145
 .*com.google\.devtools\.kythe\.extractors\.java\.JavaCompilationUnitExtractor.*
-^WARNING: Empty java source file.*
\ No newline at end of file
+^WARNING: Empty java source file.*
+# b/359397747 Add PrivacySandboxService to mediatee-sdk-provider
+> Transform sdk\-interface\-descriptors\.jar \(project :privacysandbox:ui:integration\-tests:testsdkproviderwrapper\) with ExtractCompileSdkShimTransform
+> Task :privacysandbox:ui:integration\-tests:mediateesdkproviderwrapper:buildModuleForBundle
+> Task :privacysandbox:ui:integration\-tests:mediateesdkproviderwrapper:bundle
diff --git a/development/publishScan.sh b/development/publishScan.sh
index 8845567..778fec2 100755
--- a/development/publishScan.sh
+++ b/development/publishScan.sh
@@ -30,15 +30,11 @@
   fi
 fi
 # find scan dir
-if [ "$OUT_DIR" != "" ]; then
-  effectiveGradleUserHome="$OUT_DIR/.gradle"
-else
-  if [ "$GRADLE_USER_HOME" != "" ]; then
-    effectiveGradleUserHome="$GRADLE_USER_HOME"
-  else
-    effectiveGradleUserHome="$HOME/.gradle"
-  fi
+if [ -z "${OUT_DIR+x}" ] ; then
+  SCRIPT_PATH="$(cd $(dirname $0) && pwd -P)"
+  export OUT_DIR=$SCRIPT_PATH/../../../out
 fi
+effectiveGradleUserHome="$OUT_DIR/.gradle"
 scanDir="$effectiveGradleUserHome/build-scan-data"
 
 function downloadScan() {
diff --git a/development/studio/studio.vmoptions b/development/studio/studio.vmoptions
index c70257b..166d646 100644
--- a/development/studio/studio.vmoptions
+++ b/development/studio/studio.vmoptions
@@ -5,6 +5,8 @@
 -Djdk.attach.allowAttachSelf=true
 # b/297379481
 -Dprofiler.trace.open.mode.web=true
+# b/343774023
+-Didea.kotlin.plugin.use.k2=true
 # https://github.com/google/google-java-format#intellij-jre-config
 --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
 --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
diff --git a/development/validateRefactor.sh b/development/validateRefactor.sh
index 62e907e..f9fe9c6 100755
--- a/development/validateRefactor.sh
+++ b/development/validateRefactor.sh
@@ -119,7 +119,7 @@
 }
 function doBuild() {
   # build androidx
-  echoAndDo ./gradlew createArchive zipDocs --no-daemon --rerun-tasks --offline -Pandroidx.highMemory
+  echoAndDo ./gradlew createAllArchives zipDocs --no-daemon --rerun-tasks --offline -Pandroidx.highMemory -Pandroidx.constraints=true
   archiveName="top-of-tree-m2repository-all-0.zip"
   unzipInPlace "${tempOutPath}/dist/top-of-tree-m2repository-all-0.zip"
   unzipInPlace "${tempOutPath}/dist/docs-tip-of-tree-0.zip"
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 45a7584..18bda36 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -452,6 +452,15 @@
     docs("androidx.work:work-rxjava2:2.10.0")
     docs("androidx.work:work-rxjava3:2.10.0")
     docs("androidx.work:work-testing:2.10.0")
+    docs("androidx.xr.arcore:arcore:1.0.0-alpha01")
+    docs("androidx.xr.compose.material3:material3:1.0.0-alpha01")
+    docs("androidx.xr.compose:compose:1.0.0-alpha01")
+    docs("androidx.xr.compose:compose-testing:1.0.0-alpha01")
+    docs("androidx.xr.runtime:runtime:1.0.0-alpha01")
+    docs("androidx.xr.runtime:runtime-openxr:1.0.0-alpha01")
+    docs("androidx.xr.runtime:runtime-testing:1.0.0-alpha01")
+    docs("androidx.xr.scenecore:scenecore:1.0.0-alpha01")
+    docs("androidx.xr.scenecore:scenecore-testing:1.0.0-alpha01")
 }
 
 afterEvaluate {
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 20e9239..b28ed69 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -22,8 +22,6 @@
     // If there is not at least one samples dependency, DocsImplPlugin breaks. b/332262321
     samples("androidx.window:window-samples:1.3.0")
 
-    docsForOptionalProject(":xr:xr")
-    docsForOptionalProject(":xr:xr-material3-adaptive")
     docs(project(":activity:activity"))
     docs(project(":activity:activity-compose"))
     docs(project(":activity:activity-ktx"))
@@ -48,6 +46,7 @@
     docs(project(":benchmark:benchmark-junit4"))
     docs(project(":benchmark:benchmark-macro"))
     docs(project(":benchmark:benchmark-macro-junit4"))
+    kmpDocs(project(":benchmark:benchmark-traceprocessor"))
     docs(project(":biometric:biometric"))
     docs(project(":bluetooth:bluetooth"))
     docs(project(":bluetooth:bluetooth-testing"))
@@ -56,7 +55,6 @@
     docs(project(":camera:camera-compose"))
     docs(project(":camera:camera-core"))
     docs(project(":camera:camera-effects"))
-    docs(project(":camera:camera-effects-still-portrait"))
     docs(project(":camera:camera-extensions"))
     stubs(fileTree(dir: "../camera/camera-extensions-stub", include: ["camera-extensions-stub.jar"]))
     docs(project(":camera:camera-feature-combination-query"))
@@ -413,6 +411,16 @@
     docs(project(":work:work-rxjava2"))
     docs(project(":work:work-rxjava3"))
     docs(project(":work:work-testing"))
+    docs(project(":xr:arcore:arcore"))
+    docs(project(":xr:compose:compose"))
+    docs(project(":xr:compose:compose-testing"))
+    docs(project(":xr:compose:material3:material3"))
+    docs(project(":xr:runtime:runtime"))
+    docs(project(":xr:runtime:runtime-openxr"))
+    docs(project(":xr:runtime:runtime-testing"))
+    docs(project(":xr:scenecore:scenecore"))
+    docs(project(":xr:scenecore:scenecore-testing"))
+    docs(project(":xr:xr-stubs"))
 }
 afterEvaluate {
   tasks["docs"].doFirst {
diff --git a/docs/api_guidelines/compat.md b/docs/api_guidelines/compat.md
index 78b351c..b258646 100644
--- a/docs/api_guidelines/compat.md
+++ b/docs/api_guidelines/compat.md
@@ -42,140 +42,6 @@
 }
 ```
 
-##### Preventing invalid casting {#compat-casting}
-
-Even when a call to a new API is moved to a version-specific class, a class
-verification failure is still possible when referencing types introduced in new
-APIs.
-
-When a type does not exist on a device, the verifier treats the type as
-`Object`. This is a problem if the new type is implicitly cast to a different
-type which does exist on the device.
-
-In general, if `A extends B`, using an `A` as a `B` without an explicit cast is
-fine. However, if `A` was introduced at a later API level than `B`, on devices
-below that API level, `A` will be seen as `Object`. An `Object` cannot be used
-as a `B` without an explicit cast. However, adding an explicit cast to `B` won't
-fix this, because the compiler will see the cast as redundant (as it normally
-would be). So, implicit casts between types introduced at different API levels
-should be moved out to version-specific static inner classes.
-
-The `ImplicitCastClassVerificationFailure` lint check detects and provides
-autofixes for instances of invalid implicit casts.
-
-For instance, the following would **not** be valid, because it implicitly casts
-an `AdaptiveIconDrawable` (new in API level 26, `Object` on lower API levels) to
-`Drawable`. Instead, the method inside of `Api26Impl` could return `Drawable`,
-or the cast could be moved into a version-specific static inner class.
-
-```java {.bad}
-private Drawable methodReturnsDrawable() {
-  if (Build.VERSION.SDK_INT >= 26) {
-    // Implicitly casts the returned AdaptiveIconDrawable to Drawable
-    return Api26Impl.createAdaptiveIconDrawable(null, null);
-  } else {
-    return null;
-  }
-}
-
-@RequiresApi(26)
-static class Api26Impl {
-  // Returns AdaptiveIconDrawable, introduced in API level 26
-  @DoNotInline
-  static AdaptiveIconDrawable createAdaptiveIconDrawable(Drawable backgroundDrawable, Drawable foregroundDrawable) {
-    return new AdaptiveIconDrawable(backgroundDrawable, foregroundDrawable);
-  }
-}
-```
-
-The version-specific static inner class solution would look like this:
-
-```java {.good}
-private Drawable methodReturnsDrawable() {
-  if (Build.VERSION.SDK_INT >= 26) {
-    return Api26Impl.castToDrawable(Api26Impl.createAdaptiveIconDrawable(null, null));
-  } else {
-    return null;
-  }
-}
-
-@RequiresApi(26)
-static class Api26Impl {
-  // Returns AdaptiveIconDrawable, introduced in API level 26
-  @DoNotInline
-  static AdaptiveIconDrawable createAdaptiveIconDrawable(Drawable backgroundDrawable, Drawable foregroundDrawable) {
-    return new AdaptiveIconDrawable(backgroundDrawable, foregroundDrawable);
-  }
-
-  // Method which performs the implicit cast from AdaptiveIconDrawable to Drawable
-  @DoNotInline
-  static Drawable castToDrawable(AdaptiveIconDrawable adaptiveIconDrawable) {
-    return adaptiveIconDrawable;
-  }
-}
-```
-
-The following would also **not** be valid, because it implicitly casts a
-`Notification.MessagingStyle` (new in API level 24, `Object` on lower API
-levels) to `Notification.Style`. Instead, `Api24Impl` could have a `setBuilder`
-method which takes `Notification.MessagingStyle` as a parameter, or the cast
-could be moved into a version-specific static inner class.
-
-```java {.bad}
-public void methodUsesStyle(Notification.MessagingStyle style, Notification.Builder builder) {
-  if (Build.VERSION.SDK_INT >= 24) {
-    Api16Impl.setBuilder(
-      // Implicitly casts the style to Notification.Style (added in API level 16)
-      // when it is a Notification.MessagingStyle (added in API level 24)
-      style, builder
-    );
-  }
-}
-
-@RequiresApi(16)
-static class Api16Impl {
-  private Api16Impl() { }
-
-  @DoNotInline
-  static void setBuilder(Notification.Style style, Notification.Builder builder) {
-    style.setBuilder(builder);
-  }
-}
-```
-
-The version-specific static inner class solution would look like this:
-
-```java {.good}
-public void methodUsesStyle(Notification.MessagingStyle style, Notification.Builder builder) {
-  if (Build.VERSION.SDK_INT >= 24) {
-    Api16Impl.setBuilder(
-      Api24Impl.castToStyle(style), builder
-    );
-  }
-}
-
-@RequiresApi(16)
-static class Api16Impl {
-  private Api16Impl() { }
-
-  @DoNotInline
-  static void setBuilder(Notification.Style style, Notification.Builder builder) {
-    style.setBuilder(builder);
-  }
-}
-
-@RequiresApi(24)
-static class Api24Impl {
-  private Api24Impl() { }
-
-  // Performs the implicit cast from Notification.MessagingStyle to Notification.Style
-  @DoNotInline
-  static Notification.Style castToStyle(Notification.MessagingStyle messagingStyle) {
-    return messagingStyle;
-  }
-}
-```
-
 #### Validating class verification
 
 To verify that your library does not raise class verification failures, look for
diff --git a/docs/api_guidelines/platform_compat.md b/docs/api_guidelines/platform_compat.md
index 40b4a74..95954ac 100644
--- a/docs/api_guidelines/platform_compat.md
+++ b/docs/api_guidelines/platform_compat.md
@@ -299,52 +299,3 @@
 the fact that Room generates code dynamically, means that Room interfaces can be
 used in host-side tests (though actual DB code should be tested on device, since
 DB impls may be significantly different on host).
-
-### Addressing class verification failures on `super.` invocation {#compat-super}
-
-Invoking a `super` call on a method introduced in an API level higher than a
-class's minimum SDK level will raise a run-time class verification failure, and
-will be detected by the `ClassVerificationFailure` lint check.
-
-```java {.bad}
-public void performAction() {
-  if (SDK_INT >= 31) {
-    super.performAction(); // This will cause a verification failure.
-  }
-}
-```
-
-These failures can be addressed by out-of-lining the `super` call to a
-non-static inner class.
-
-#### Sample {#compat-super-sample}
-
-```java
-class AppCompatTextView : TextView {
-
-  @Nullable
-  SuperCaller mSuperCaller = null;
-
-  @Override
-  int getPropertyFromApi99() {
-  if (Build.VERSION.SDK_INT > 99) {
-    getSuperCaller().getPropertyFromApi99)();
-  }
-
-  @NonNull
-  @RequiresApi(99)
-  private SuperCaller getSuperCaller() {
-    if (mSuperCaller == null) {
-      mSuperCaller = new Api99SuperCaller();
-    }
-    return mSuperCaller;
-  }
-
-  @RequiresApi(99)
-  private class Api99SuperCaller {
-    int getPropertyFromApi99() {
-      return AppCompatTextView.super.getPropertyFromApi99();
-    }
-  }
-}
-```
diff --git a/emoji/emoji/build.gradle b/emoji/emoji/build.gradle
index ef08527..6b429dc 100644
--- a/emoji/emoji/build.gradle
+++ b/emoji/emoji/build.gradle
@@ -44,7 +44,6 @@
 android {
     sourceSets {
         main {
-            res.srcDirs += "src/main/res-public"
             resources {
                 srcDirs += [fontDir.getAbsolutePath()]
                 includes += ["LICENSE_UNICODE", "LICENSE_OFL"]
diff --git a/emoji/emoji/src/main/res-public/values/public_attrs.xml b/emoji/emoji/src/main/res/values/public_attrs.xml
similarity index 100%
rename from emoji/emoji/src/main/res-public/values/public_attrs.xml
rename to emoji/emoji/src/main/res/values/public_attrs.xml
diff --git a/emoji2/emoji2-benchmark/build.gradle b/emoji2/emoji2-benchmark/build.gradle
index 2e0b2d5..ed2ec2f 100644
--- a/emoji2/emoji2-benchmark/build.gradle
+++ b/emoji2/emoji2-benchmark/build.gradle
@@ -58,7 +58,7 @@
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
     androidTestImplementation(libs.kotlinStdlib)
 }
 
diff --git a/emoji2/emoji2-bundled/build.gradle b/emoji2/emoji2-bundled/build.gradle
index 7782803..171787cf 100644
--- a/emoji2/emoji2-bundled/build.gradle
+++ b/emoji2/emoji2-bundled/build.gradle
@@ -47,10 +47,10 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
 
     // view tests that use font are in this module as well; for licensing reasons
-    androidTestImplementation project(":emoji2:emoji2-views")
+    androidTestImplementation(project(":emoji2:emoji2-views"))
 }
 
 androidx {
diff --git a/emoji2/emoji2-emojipicker/build.gradle b/emoji2/emoji2-emojipicker/build.gradle
index bdead6a..a5a03eb 100644
--- a/emoji2/emoji2-emojipicker/build.gradle
+++ b/emoji2/emoji2-emojipicker/build.gradle
@@ -54,10 +54,6 @@
     defaultConfig {
         vectorDrawables.useSupportLibrary = true
     }
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/emoji2/emoji2-emojipicker"
-    }
-
     namespace = "androidx.emoji2.emojipicker"
     testOptions.unitTests.includeAndroidResources = true
 }
@@ -69,4 +65,5 @@
     description = "This library provides the latest emoji support and emoji picker UI to input " +
             "emoji in current and older devices"
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
diff --git a/emoji2/emoji2-views-helper/build.gradle b/emoji2/emoji2-views-helper/build.gradle
index 59d5096..5ceae4e 100644
--- a/emoji2/emoji2-views-helper/build.gradle
+++ b/emoji2/emoji2-views-helper/build.gradle
@@ -26,7 +26,7 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
 }
 
 android {
diff --git a/emoji2/emoji2-views/build.gradle b/emoji2/emoji2-views/build.gradle
index d1145d1..7db1ff8 100644
--- a/emoji2/emoji2-views/build.gradle
+++ b/emoji2/emoji2-views/build.gradle
@@ -27,15 +27,10 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
 }
 
 android {
-    sourceSets {
-        main {
-            res.srcDirs += 'src/main/res-public'
-        }
-    }
     namespace = "androidx.emoji2.widget"
 }
 
diff --git a/emoji2/emoji2-views/src/main/res-public/values/public_attrs.xml b/emoji2/emoji2-views/src/main/res/values/public_attrs.xml
similarity index 100%
rename from emoji2/emoji2-views/src/main/res-public/values/public_attrs.xml
rename to emoji2/emoji2-views/src/main/res/values/public_attrs.xml
diff --git a/emoji2/emoji2/build.gradle b/emoji2/emoji2/build.gradle
index 083f71c..adecc35 100644
--- a/emoji2/emoji2/build.gradle
+++ b/emoji2/emoji2/build.gradle
@@ -41,7 +41,7 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
 }
 
 androidx {
diff --git a/glance/glance-appwidget/build.gradle b/glance/glance-appwidget/build.gradle
index d7e8d3c..098cf0f 100644
--- a/glance/glance-appwidget/build.gradle
+++ b/glance/glance-appwidget/build.gradle
@@ -88,11 +88,6 @@
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
     resourcePrefix "glance_"
-
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/glance/glance-appwidget"
-    }
-
     buildTypes {
         debug {
             pseudoLocalesEnabled = true
@@ -118,6 +113,7 @@
     legacyDisableKotlinStrictApiMode = true
     metalavaK2UastEnabled = false
     samples(project(":glance:glance-appwidget:glance-appwidget-samples"))
+    addGoldenImageAssets()
 }
 
 LayoutGeneratorTask.registerLayoutGenerator(
diff --git a/glance/glance-wear-tiles/build.gradle b/glance/glance-wear-tiles/build.gradle
index da875d3..a21f20d 100644
--- a/glance/glance-wear-tiles/build.gradle
+++ b/glance/glance-wear-tiles/build.gradle
@@ -84,12 +84,7 @@
         // protobuf generates unannotated methods
         disable "UnknownNullness"
     }
-
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/glance/glance-wear-tiles"
-    }
     namespace = "androidx.glance.wear.tiles"
-
 }
 
 androidx {
@@ -101,4 +96,5 @@
             "Compose-style API."
     legacyDisableKotlinStrictApiMode = true
     metalavaK2UastEnabled = false
+    addGoldenImageAssets()
 }
diff --git a/gradle.properties b/gradle.properties
index ecc3a8f..4442086 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -62,7 +62,7 @@
 android.experimental.dependency.excludeLibraryComponentsFromConstraints=true
 # Disallow resolving dependencies at configuration time, which is a slight performance problem
 android.dependencyResolutionAtConfigurationTime.disallow=true
-android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.lint.reservedMemoryPerTask,android.experimental.dependency.excludeLibraryComponentsFromConstraints,android.prefabVersion,android.experimental.privacysandboxsdk.plugin.enable,android.experimental.privacysandboxsdk.requireServices,android.lint.useK2Uast
+android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.lint.reservedMemoryPerTask,android.experimental.dependency.excludeLibraryComponentsFromConstraints,android.prefabVersion,android.experimental.privacysandboxsdk.plugin.enable,android.experimental.privacysandboxsdk.requireServices,android.lint.useK2Uast,android.experimental.skipApksViaBundleIfPossible
 # Workaround for b/162074215
 android.includeDependencyInfoInApks=false
 # Allow multiple r8 tasks at once because otherwise they can make the critical path longer: b/256187923
@@ -94,6 +94,9 @@
 # Allow non-shim usage
 android.experimental.privacysandboxsdk.requireServices=false
 
+# Use fast-path APKs from AGP, ensuring that single APK will be used (for FTL configs)
+android.experimental.skipApksViaBundleIfPossible=true
+
 # Annotation processors discovery from compile classpath is deprecated
 kapt.include.compile.classpath=false
 
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 918ddde..d554032 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -38,6 +38,7 @@
          <trust group="com.google.devsite" reason="developed by our own team, not signed yet"/>
          <trust group="com.google.firebase" reason="b/223907608"/>
          <trust group="com.google.guava" reasons="b/325084664 https://github.com/gradle/gradle/issues/28862"/>
+         <trust group="com.google.ar" reason="b/378946732"/>
          <trust group="com.google.mlkit" reason="b/223907608"/>
          <trust group="com.google.prefab" reason="https://github.com/google/prefab/issues/157"/>
          <trust group="com.google.testing.platform" reason="b/215430394"/>
diff --git a/graphics/graphics-core/samples/build.gradle b/graphics/graphics-core/samples/build.gradle
index c3edfbf..655493e 100644
--- a/graphics/graphics-core/samples/build.gradle
+++ b/graphics/graphics-core/samples/build.gradle
@@ -27,8 +27,8 @@
 dependencies {
     implementation(libs.kotlinStdlib)
 
-    compileOnly project(":annotation:annotation-sampled")
-    implementation project(":graphics:graphics-core")
+    compileOnly(project(":annotation:annotation-sampled"))
+    implementation(project(":graphics:graphics-core"))
     implementation "androidx.annotation:annotation:1.8.1"
 }
 
diff --git a/health/connect/connect-client/samples/build.gradle b/health/connect/connect-client/samples/build.gradle
index 0f3267b..0c314fa 100644
--- a/health/connect/connect-client/samples/build.gradle
+++ b/health/connect/connect-client/samples/build.gradle
@@ -33,8 +33,8 @@
 dependencies {
     implementation(libs.kotlinStdlib)
 
-    compileOnly project(":annotation:annotation-sampled")
-    implementation project(":health:connect:connect-client")
+    compileOnly(project(":annotation:annotation-sampled"))
+    implementation(project(":health:connect:connect-client"))
     implementation ("androidx.appcompat:appcompat:1.6.0")
     implementation ("androidx.activity:activity:1.6.0")
 }
diff --git a/health/connect/connect-testing/build.gradle b/health/connect/connect-testing/build.gradle
index 52cc4f4..7f12554 100644
--- a/health/connect/connect-testing/build.gradle
+++ b/health/connect/connect-testing/build.gradle
@@ -32,7 +32,7 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    implementation project(":health:connect:connect-client")
+    implementation(project(":health:connect:connect-client"))
     implementation(project(":health:connect:connect-client-proto"))
 
     testImplementation(libs.kotlinTest)
diff --git a/hilt/hilt-navigation-compose/build.gradle b/hilt/hilt-navigation-compose/build.gradle
index 2d97039..63a965d 100644
--- a/hilt/hilt-navigation-compose/build.gradle
+++ b/hilt/hilt-navigation-compose/build.gradle
@@ -45,7 +45,7 @@
 dependencies {
 
     implementation(libs.kotlinStdlib)
-    api project(":hilt:hilt-navigation")
+    api(project(":hilt:hilt-navigation"))
     api("androidx.compose.runtime:runtime:1.0.1")
     api("androidx.compose.ui:ui:1.0.1")
     api("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
diff --git a/ink/ink-authoring/build.gradle b/ink/ink-authoring/build.gradle
index fe47346..ab75911 100644
--- a/ink/ink-authoring/build.gradle
+++ b/ink/ink-authoring/build.gradle
@@ -18,56 +18,54 @@
 import androidx.build.PlatformIdentifier
 
 plugins {
-  id("AndroidXPlugin")
-  id("com.android.library")
+    id("AndroidXPlugin")
+    id("com.android.library")
 }
 
 androidXMultiplatform {
-  android()
+    android()
 
-  defaultPlatform(PlatformIdentifier.ANDROID)
+    defaultPlatform(PlatformIdentifier.ANDROID)
 
-  sourceSets {
+    sourceSets {
 
-    androidMain {
-      dependencies {
-        implementation("androidx.collection:collection:1.4.3")
-        implementation("androidx.graphics:graphics-core:1.0.2")
-        implementation("androidx.fragment:fragment-ktx:1.3.0")
-        implementation("androidx.test.espresso:espresso-idling-resource:3.5.0")
-        implementation("androidx.core:core:1.1.0")
-        implementation("androidx.core:core-ktx:1.12.0")
-        implementation(project(":ink:ink-nativeloader"))
-        implementation(project(":ink:ink-geometry"))
-        implementation(project(":ink:ink-brush"))
-        implementation(project(":ink:ink-strokes"))
-        implementation(project(":ink:ink-rendering"))
-      }
+        androidMain {
+            dependencies {
+                implementation("androidx.collection:collection:1.4.3")
+                implementation("androidx.graphics:graphics-core:1.0.2")
+                implementation("androidx.fragment:fragment-ktx:1.3.0")
+                implementation("androidx.test.espresso:espresso-idling-resource:3.5.0")
+                implementation("androidx.core:core:1.1.0")
+                implementation("androidx.core:core-ktx:1.12.0")
+                implementation(project(":ink:ink-nativeloader"))
+                implementation(project(":ink:ink-geometry"))
+                implementation(project(":ink:ink-brush"))
+                implementation(project(":ink:ink-strokes"))
+                implementation(project(":ink:ink-rendering"))
+            }
+        }
+
+        androidInstrumentedTest {
+            dependencies {
+                implementation(libs.testExtJunit)
+                implementation(libs.testRules)
+                implementation(libs.testRunner)
+                implementation(libs.espressoCore)
+                implementation(libs.junit)
+                implementation(libs.kotlinTest)
+                implementation(libs.mockitoCore4)
+                implementation(libs.mockitoKotlin4)
+                implementation(libs.dexmakerMockito)
+                implementation(libs.truth)
+                implementation(project(":test:screenshot:screenshot"))
+            }
+        }
     }
-
-    androidInstrumentedTest {
-      dependencies {
-        implementation(libs.testExtJunit)
-        implementation(libs.testRules)
-        implementation(libs.testRunner)
-        implementation(libs.espressoCore)
-        implementation(libs.junit)
-        implementation(libs.kotlinTest)
-        implementation(libs.mockitoCore4)
-        implementation(libs.mockitoKotlin4)
-        implementation(libs.dexmakerMockito)
-        implementation(libs.truth)
-        implementation(project(":test:screenshot:screenshot"))
-      }
-    }
-  }
 }
 
 android {
-  namespace = "androidx.ink.authoring"
-  compileSdk = 35
-  sourceSets.androidTest.assets.srcDirs +=
-      project.rootDir.absolutePath + "/../../golden/ink/ink-authoring"
+    namespace = "androidx.ink.authoring"
+    compileSdk = 35
 }
 
 androidx {
@@ -75,4 +73,5 @@
     type = LibraryType.PUBLISHED_LIBRARY
     inceptionYear = "2024"
     description = "Author beautiful strokes"
+    addGoldenImageAssets()
 }
diff --git a/ink/ink-rendering/build.gradle b/ink/ink-rendering/build.gradle
index 5356b1a..ddce6b9 100644
--- a/ink/ink-rendering/build.gradle
+++ b/ink/ink-rendering/build.gradle
@@ -18,49 +18,46 @@
 import androidx.build.PlatformIdentifier
 
 plugins {
-  id("AndroidXPlugin")
-  id("com.android.library")
+    id("AndroidXPlugin")
+    id("com.android.library")
 }
 
 androidXMultiplatform {
-  android()
+    android()
 
-  defaultPlatform(PlatformIdentifier.ANDROID)
+    defaultPlatform(PlatformIdentifier.ANDROID)
 
-  sourceSets {
+    sourceSets {
+        androidMain {
+            dependencies {
+                implementation("androidx.collection:collection:1.4.3")
+                implementation("androidx.core:core:1.1.0")
+                implementation("androidx.core:core-ktx:1.12.0")
+                implementation(project(":ink:ink-nativeloader"))
+                implementation(project(":ink:ink-geometry"))
+                implementation(project(":ink:ink-brush"))
+                implementation(project(":ink:ink-strokes"))
+            }
+        }
 
-    androidMain {
-      dependencies {
-        implementation("androidx.collection:collection:1.4.3")
-        implementation("androidx.core:core:1.1.0")
-        implementation("androidx.core:core-ktx:1.12.0")
-        implementation(project(":ink:ink-nativeloader"))
-        implementation(project(":ink:ink-geometry"))
-        implementation(project(":ink:ink-brush"))
-        implementation(project(":ink:ink-strokes"))
-      }
+        androidInstrumentedTest {
+            dependencies {
+                implementation(libs.testExtJunit)
+                implementation(libs.testRules)
+                implementation(libs.testRunner)
+                implementation(libs.espressoCore)
+                implementation(libs.junit)
+                implementation(libs.truth)
+                implementation(project(":core:core-ktx"))
+                implementation(project(":test:screenshot:screenshot"))
+            }
+        }
     }
-
-    androidInstrumentedTest {
-      dependencies {
-        implementation(libs.testExtJunit)
-        implementation(libs.testRules)
-        implementation(libs.testRunner)
-        implementation(libs.espressoCore)
-        implementation(libs.junit)
-        implementation(libs.truth)
-        implementation(project(":core:core-ktx"))
-	implementation(project(":test:screenshot:screenshot"))
-      }
-    }
-  }
 }
 
 android {
-  namespace = "androidx.ink.rendering"
-  compileSdk = 35
-  sourceSets.androidTest.assets.srcDirs +=
-      project.rootDir.absolutePath + "/../../golden/ink/ink-rendering"
+    namespace = "androidx.ink.rendering"
+    compileSdk = 35
 }
 
 androidx {
@@ -68,4 +65,5 @@
     type = LibraryType.PUBLISHED_LIBRARY
     inceptionYear = "2024"
     description = "Display beautiful strokes"
+    addGoldenImageAssets()
 }
diff --git a/javascriptengine/javascriptengine/lint.xml b/javascriptengine/javascriptengine/lint.xml
index ba04352..eee38c97 100644
--- a/javascriptengine/javascriptengine/lint.xml
+++ b/javascriptengine/javascriptengine/lint.xml
@@ -1,7 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <lint>
-    <!-- We cannot cause ClassVerificationFailure in embedding apps -->
-    <issue id="ClassVerificationFailure" severity="fatal" />
     <!-- Developers need to call our code from Kotlin code, so nullness is important.-->
     <issue id="UnknownNullness" severity="fatal" />
 </lint>
diff --git a/leanback/leanback-grid/build.gradle b/leanback/leanback-grid/build.gradle
index 0618308..9d9e7ee 100644
--- a/leanback/leanback-grid/build.gradle
+++ b/leanback/leanback-grid/build.gradle
@@ -29,6 +29,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     api("androidx.core:core:1.1.0")
     api("androidx.recyclerview:recyclerview:1.3.2")
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/BaseGridView.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/BaseGridView.java
index 2c302f1..d3ae748 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/BaseGridView.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/BaseGridView.java
@@ -28,12 +28,13 @@
 import android.view.View;
 import android.view.animation.Interpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.recyclerview.widget.SimpleItemAnimator;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An abstract base class for vertically and horizontally scrolling lists. The items come
  * from the {@link RecyclerView.Adapter} associated with this view.
@@ -181,8 +182,7 @@
          * @param dy y distance in pixels.
          * @return Interpolator to be used or null for default interpolator.
          */
-        @Nullable
-        Interpolator configSmoothScrollByInterpolator(int dx, int dy);
+        @Nullable Interpolator configSmoothScrollByInterpolator(int dx, int dy);
     }
 
     /**
@@ -236,7 +236,7 @@
          *
          * @param state Transient state of RecyclerView
          */
-        void onLayoutCompleted(@NonNull RecyclerView.State state);
+        void onLayoutCompleted(RecyclerView.@NonNull State state);
     }
 
     GridLayoutManager mLayoutManager;
@@ -281,7 +281,7 @@
         ((SimpleItemAnimator) getItemAnimator()).setSupportsChangeAnimations(false);
         super.addRecyclerListener(new RecyclerView.RecyclerListener() {
             @Override
-            public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
+            public void onViewRecycled(RecyclerView.@NonNull ViewHolder holder) {
                 mLayoutManager.onChildRecycled(holder);
             }
         });
@@ -815,7 +815,7 @@
      * @param task     Task to executed on the ViewHolder at a given position.
      */
     @SuppressWarnings("deprecation")
-    public void setSelectedPositionSmooth(final int position, @Nullable final ViewHolderTask task) {
+    public void setSelectedPositionSmooth(final int position, final @Nullable ViewHolderTask task) {
         if (task != null) {
             RecyclerView.ViewHolder vh = findViewHolderForPosition(position);
             if (vh == null || hasPendingAdapterUpdates()) {
@@ -843,7 +843,7 @@
      * @param task     Task to executed on the ViewHolder at a given position.
      */
     @SuppressWarnings("deprecation")
-    public void setSelectedPosition(final int position, @Nullable final ViewHolderTask task) {
+    public void setSelectedPosition(final int position, final @Nullable ViewHolderTask task) {
         if (task != null) {
             RecyclerView.ViewHolder vh = findViewHolderForPosition(position);
             if (vh == null || hasPendingAdapterUpdates()) {
@@ -925,7 +925,7 @@
     }
 
     @Override
-    public void setLayoutManager(@Nullable RecyclerView.LayoutManager layout) {
+    public void setLayoutManager(RecyclerView.@Nullable LayoutManager layout) {
         if (layout == null) {
             super.setLayoutManager(null);
             if (mLayoutManager != null) {
@@ -958,7 +958,7 @@
      * @param view    The view to get offsets.
      * @param offsets offsets[0] holds offset of X, offsets[1] holds offset of Y.
      */
-    public void getViewSelectedOffsets(@NonNull View view, @NonNull int[] offsets) {
+    public void getViewSelectedOffsets(@NonNull View view, int @NonNull [] offsets) {
         mLayoutManager.getViewSelectedOffsets(view, offsets);
     }
 
@@ -972,8 +972,7 @@
     }
 
     @Override
-    @Nullable
-    public View focusSearch(int direction) {
+    public @Nullable View focusSearch(int direction) {
         if (isFocused()) {
             // focusSearch(int) is called when GridView itself is focused.
             // Calling focusSearch(view, int) to get next sibling of current selected child.
@@ -1130,8 +1129,7 @@
      *
      * @return The unhandled key listener.
      */
-    @Nullable
-    public OnUnhandledKeyListener getOnUnhandledKeyListener() {
+    public @Nullable OnUnhandledKeyListener getOnUnhandledKeyListener() {
         return mOnUnhandledKeyListener;
     }
 
@@ -1290,8 +1288,7 @@
      *
      * @return Custom behavior for SmoothScrollBy(). Null for default behavior.
      */
-    @Nullable
-    public SmoothScrollByBehavior getSmoothScrollByBehavior() {
+    public @Nullable SmoothScrollByBehavior getSmoothScrollByBehavior() {
         return mSmoothScrollByBehavior;
     }
 
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProvider.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProvider.java
index 8f4ed24..c2cc1c3 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProvider.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProvider.java
@@ -15,8 +15,8 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This is the query interface to supply optional features(aka facets) on an object without the need
@@ -70,6 +70,5 @@
      *                   {@link ItemAlignmentFacet}.
      * @return Facet implementation for the facetClass or null if feature not implemented.
      */
-    @Nullable
-    Object getFacet(@NonNull Class<?> facetClass);
+    @Nullable Object getFacet(@NonNull Class<?> facetClass);
 }
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProviderAdapter.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProviderAdapter.java
index f967eaa..c75a71b 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProviderAdapter.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/FacetProviderAdapter.java
@@ -15,7 +15,7 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Optional interface that implemented by
@@ -35,6 +35,5 @@
      * @param type type of the item.
      * @return Facet provider for the type.
      */
-    @Nullable
-    FacetProvider getFacetProvider(int type);
+    @Nullable FacetProvider getFacetProvider(int type);
 }
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/Grid.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/Grid.java
index 2b37bd4..28e2f6a 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/Grid.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/Grid.java
@@ -17,11 +17,12 @@
 
 import android.util.SparseIntArray;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.collection.CircularIntArray;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.PrintWriter;
 import java.util.Arrays;
 
@@ -292,7 +293,7 @@
      * Finds the largest or smallest row min edge of visible items,
      * the row index is returned in indices[0], the item index is returned in indices[1].
      */
-    public final int findRowMin(boolean findLarge, @Nullable int[] indices) {
+    public final int findRowMin(boolean findLarge, int @Nullable [] indices) {
         return findRowMin(findLarge, mReversedFlow ? mLastVisibleIndex : mFirstVisibleIndex,
                 indices);
     }
@@ -308,7 +309,7 @@
      * Finds the largest or smallest row max edge of visible items, the row index is returned in
      * indices[0], the item index is returned in indices[1].
      */
-    public final int findRowMax(boolean findLarge, @Nullable int[] indices) {
+    public final int findRowMax(boolean findLarge, int @Nullable [] indices) {
         return findRowMax(findLarge, mReversedFlow ? mFirstVisibleIndex : mLastVisibleIndex,
                 indices);
     }
@@ -538,7 +539,7 @@
      * Queries items adjacent to the viewport (in the direction of da) into the prefetch registry.
      */
     public void collectAdjacentPrefetchPositions(int fromLimit, int da,
-            @NonNull RecyclerView.LayoutManager.LayoutPrefetchRegistry layoutPrefetchRegistry) {
+            RecyclerView.LayoutManager.@NonNull LayoutPrefetchRegistry layoutPrefetchRegistry) {
     }
 
     public abstract void debugPrint(PrintWriter pw);
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/GridLayoutManager.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/GridLayoutManager.java
index 2feb9f3..ceb8a93 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/GridLayoutManager.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/GridLayoutManager.java
@@ -44,8 +44,6 @@
 import android.view.animation.AccelerateDecelerateInterpolator;
 import android.widget.GridView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 import androidx.collection.CircularIntArray;
 import androidx.core.view.ViewCompat;
@@ -56,6 +54,9 @@
 import androidx.recyclerview.widget.RecyclerView.Recycler;
 import androidx.recyclerview.widget.RecyclerView.State;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.util.ArrayList;
@@ -1093,7 +1094,7 @@
     }
 
     @Override
-    public boolean checkLayoutParams(@Nullable RecyclerView.LayoutParams lp) {
+    public boolean checkLayoutParams(RecyclerView.@Nullable LayoutParams lp) {
         return lp instanceof LayoutParams;
     }
 
@@ -1114,9 +1115,8 @@
     /**
      * {@inheritDoc}
      */
-    @NonNull
     @Override
-    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
+    public RecyclerView.@NonNull LayoutParams generateDefaultLayoutParams() {
         return new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                 ViewGroup.LayoutParams.WRAP_CONTENT);
     }
@@ -1124,9 +1124,8 @@
     /**
      * {@inheritDoc}
      */
-    @NonNull
     @Override
-    public RecyclerView.LayoutParams generateLayoutParams(@NonNull Context context,
+    public RecyclerView.@NonNull LayoutParams generateLayoutParams(@NonNull Context context,
             @NonNull AttributeSet attrs) {
         return new LayoutParams(context, attrs);
     }
@@ -1134,9 +1133,9 @@
     /**
      * {@inheritDoc}
      */
-    @NonNull
     @Override
-    public RecyclerView.LayoutParams generateLayoutParams(@NonNull ViewGroup.LayoutParams lp) {
+    public RecyclerView.@NonNull LayoutParams generateLayoutParams(
+            ViewGroup.@NonNull LayoutParams lp) {
         if (lp instanceof LayoutParams) {
             return new LayoutParams((LayoutParams) lp);
         } else if (lp instanceof RecyclerView.LayoutParams) {
@@ -2091,7 +2090,7 @@
     }
 
     @Override
-    public void removeAndRecycleAllViews(@NonNull RecyclerView.Recycler recycler) {
+    public void removeAndRecycleAllViews(RecyclerView.@NonNull Recycler recycler) {
         if (DEBUG) Log.v(TAG, "removeAndRecycleAllViews " + getChildCount());
         for (int i = getChildCount() - 1; i >= 0; i--) {
             removeAndRecycleViewAt(i, recycler);
@@ -2204,8 +2203,8 @@
 
     // Lays out items based on the current scroll position
     @Override
-    public void onLayoutChildren(@NonNull RecyclerView.Recycler recycler,
-            @NonNull RecyclerView.State state) {
+    public void onLayoutChildren(RecyclerView.@NonNull Recycler recycler,
+            RecyclerView.@NonNull State state) {
         if (DEBUG) {
             Log.v(getTag(), "layoutChildren start numRows " + mNumRows
                     + " inPreLayout " + state.isPreLayout()
@@ -2433,7 +2432,7 @@
 
     @Override
     public int scrollHorizontallyBy(int dx, @NonNull Recycler recycler,
-            @NonNull RecyclerView.State state) {
+            RecyclerView.@NonNull State state) {
         if (DEBUG) Log.v(getTag(), "scrollHorizontallyBy " + dx);
         if ((mFlag & PF_LAYOUT_ENABLED) == 0 || !hasDoneFirstLayout()) {
             return 0;
@@ -2453,7 +2452,7 @@
 
     @Override
     public int scrollVerticallyBy(int dy, @NonNull Recycler recycler,
-            @NonNull RecyclerView.State state) {
+            RecyclerView.@NonNull State state) {
         if (DEBUG) Log.v(getTag(), "scrollVerticallyBy " + dy);
         if ((mFlag & PF_LAYOUT_ENABLED) == 0 || !hasDoneFirstLayout()) {
             return 0;
@@ -2810,7 +2809,7 @@
     }
 
     @Override
-    public void startSmoothScroll(@NonNull RecyclerView.SmoothScroller smoothScroller) {
+    public void startSmoothScroll(RecyclerView.@NonNull SmoothScroller smoothScroller) {
         skipSmoothScrollerOnStopInternal();
         super.startSmoothScroll(smoothScroller);
         if (smoothScroller.isRunning() && smoothScroller instanceof GridLinearSmoothScroller) {
@@ -3255,9 +3254,8 @@
         return (mFlag & PF_FOCUS_SEARCH_DISABLED) != 0;
     }
 
-    @Nullable
     @Override
-    public View onInterceptFocusSearch(@Nullable View focused, int direction) {
+    public @Nullable View onInterceptFocusSearch(@Nullable View focused, int direction) {
         if ((mFlag & PF_FOCUS_SEARCH_DISABLED) != 0) {
             return focused;
         }
@@ -3636,8 +3634,8 @@
     }
 
     @Override
-    public void onAdapterChanged(@Nullable RecyclerView.Adapter oldAdapter,
-            @Nullable RecyclerView.Adapter newAdapter) {
+    public void onAdapterChanged(RecyclerView.@Nullable Adapter oldAdapter,
+            RecyclerView.@Nullable Adapter newAdapter) {
         if (DEBUG) Log.v(getTag(), "onAdapterChanged to " + newAdapter);
         if (oldAdapter != null) {
             discardLayoutInfo();
@@ -3716,9 +3714,8 @@
         }
     }
 
-    @NonNull
     @Override
-    public Parcelable onSaveInstanceState() {
+    public @NonNull Parcelable onSaveInstanceState() {
         if (DEBUG) Log.v(getTag(), "onSaveInstanceState getSelection() " + getSelection());
         SavedState ss = new SavedState();
         // save selected index
@@ -3759,8 +3756,8 @@
     }
 
     @Override
-    public int getRowCountForAccessibility(@NonNull RecyclerView.Recycler recycler,
-            @NonNull RecyclerView.State state) {
+    public int getRowCountForAccessibility(RecyclerView.@NonNull Recycler recycler,
+            RecyclerView.@NonNull State state) {
         if (mOrientation == HORIZONTAL && mGrid != null) {
             return mGrid.getNumRows();
         }
@@ -3768,8 +3765,8 @@
     }
 
     @Override
-    public int getColumnCountForAccessibility(@NonNull RecyclerView.Recycler recycler,
-            @NonNull RecyclerView.State state) {
+    public int getColumnCountForAccessibility(RecyclerView.@NonNull Recycler recycler,
+            RecyclerView.@NonNull State state) {
         if (mOrientation == VERTICAL && mGrid != null) {
             return mGrid.getNumRows();
         }
@@ -3777,8 +3774,8 @@
     }
 
     @Override
-    public void onInitializeAccessibilityNodeInfoForItem(@NonNull RecyclerView.Recycler recycler,
-            @NonNull RecyclerView.State state, @NonNull View host,
+    public void onInitializeAccessibilityNodeInfoForItem(RecyclerView.@NonNull Recycler recycler,
+            RecyclerView.@NonNull State state, @NonNull View host,
             @NonNull AccessibilityNodeInfoCompat info) {
         ViewGroup.LayoutParams lp = host.getLayoutParams();
         if (mGrid == null || !(lp instanceof LayoutParams)) {
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/HorizontalGridView.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/HorizontalGridView.java
index 6987c74..260247c 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/HorizontalGridView.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/HorizontalGridView.java
@@ -31,11 +31,12 @@
 import android.util.TypedValue;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A {@link android.view.ViewGroup} that shows items in a horizontal scrolling list. The items
  * come from
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ItemAlignmentFacet.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ItemAlignmentFacet.java
index 9f26cb9..5645608 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ItemAlignmentFacet.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ItemAlignmentFacet.java
@@ -17,7 +17,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Optional facet provided by {@link androidx.recyclerview.widget.RecyclerView.Adapter} or
@@ -198,7 +198,7 @@
      * Sets definitions of alignment positions.
      */
     public void setAlignmentDefs(
-            @SuppressWarnings("ArrayReturn") @NonNull ItemAlignmentDef[] defs) {
+            @SuppressWarnings("ArrayReturn") ItemAlignmentDef @NonNull [] defs) {
         if (defs == null || defs.length < 1) {
             throw new IllegalArgumentException();
         }
@@ -209,8 +209,7 @@
      * Returns read only definitions of alignment positions.
      */
     @SuppressWarnings("ArrayReturn")
-    @NonNull
-    public ItemAlignmentDef[] getAlignmentDefs() {
+    public ItemAlignmentDef @NonNull [] getAlignmentDefs() {
         return mAlignmentDefs;
     }
 }
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildLaidOutListener.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildLaidOutListener.java
index 8689dc3..d7d44f9 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildLaidOutListener.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildLaidOutListener.java
@@ -18,7 +18,7 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Interface for receiving notification when a child of this
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildSelectedListener.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildSelectedListener.java
index 0d9df1e..752ed81 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildSelectedListener.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildSelectedListener.java
@@ -18,8 +18,8 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface for receiving notification when a child of this
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildViewHolderSelectedListener.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildViewHolderSelectedListener.java
index 42e3e57..f3ae760 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildViewHolderSelectedListener.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/OnChildViewHolderSelectedListener.java
@@ -17,11 +17,12 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.widget.ItemAlignmentFacet.ItemAlignmentDef;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Interface for receiving notification when a child of this ViewGroup has been selected.
  * There are two methods:
@@ -54,7 +55,7 @@
      *                    0 if there is no ItemAlignmentDef defined for the item.
      */
     public void onChildViewHolderSelected(@NonNull RecyclerView parent,
-            @Nullable RecyclerView.ViewHolder child,
+            RecyclerView.@Nullable ViewHolder child,
             int position, int subposition) {
     }
 
@@ -71,6 +72,6 @@
      *                    0 if there is no ItemAlignmentDef defined for the item.
      */
     public void onChildViewHolderSelectedAndPositioned(@NonNull RecyclerView parent,
-            @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+            RecyclerView.@Nullable ViewHolder child, int position, int subposition) {
     }
 }
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/SingleRow.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/SingleRow.java
index 279d407..8385011 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/SingleRow.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/SingleRow.java
@@ -15,10 +15,11 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
 import androidx.collection.CircularIntArray;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.PrintWriter;
 
 /**
@@ -136,7 +137,7 @@
 
     @Override
     public void collectAdjacentPrefetchPositions(int fromLimit, int da,
-            @NonNull RecyclerView.LayoutManager.LayoutPrefetchRegistry layoutPrefetchRegistry) {
+            RecyclerView.LayoutManager.@NonNull LayoutPrefetchRegistry layoutPrefetchRegistry) {
         int indexToPrefetch;
         int nearestEdge;
         if (mReversedFlow ? da > 0 : da < 0) {
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/VerticalGridView.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/VerticalGridView.java
index 8f69a4e..52ff29d 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/VerticalGridView.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/VerticalGridView.java
@@ -21,11 +21,12 @@
 import android.util.AttributeSet;
 import android.util.TypedValue;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A {@link android.view.ViewGroup} that shows items in a vertically scrolling list. The items
  * come from the {@link RecyclerView.Adapter} associated with this view.
diff --git a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ViewHolderTask.java b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ViewHolderTask.java
index 0c1b7f3..ec3e70d 100644
--- a/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ViewHolderTask.java
+++ b/leanback/leanback-grid/src/main/java/androidx/leanback/widget/ViewHolderTask.java
@@ -15,9 +15,10 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Interface for schedule task on a ViewHolder.
  */
@@ -25,5 +26,5 @@
     /**
      * Runs the task.
      */
-    void run(@NonNull RecyclerView.ViewHolder viewHolder);
+    void run(RecyclerView.@NonNull ViewHolder viewHolder);
 }
diff --git a/leanback/leanback-preference/build.gradle b/leanback/leanback-preference/build.gradle
index 6410111..48313f2 100644
--- a/leanback/leanback-preference/build.gradle
+++ b/leanback/leanback-preference/build.gradle
@@ -13,6 +13,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     implementation("androidx.collection:collection:1.4.2")
     api("androidx.appcompat:appcompat:1.0.0")
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/BaseLeanbackPreferenceFragmentCompat.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/BaseLeanbackPreferenceFragmentCompat.java
index 26550ba..036c9e6 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/BaseLeanbackPreferenceFragmentCompat.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/BaseLeanbackPreferenceFragmentCompat.java
@@ -25,7 +25,6 @@
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.widget.VerticalGridView;
@@ -33,6 +32,8 @@
 import androidx.preference.PreferenceRecyclerViewAccessibilityDelegate;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * This fragment provides a preference fragment with leanback-style behavior, suitable for
  * embedding into broader UI elements.
@@ -41,9 +42,8 @@
 
     private Context mThemedContext;
 
-    @Nullable
     @Override
-    public Context getContext() {
+    public @Nullable Context getContext() {
         if (mThemedContext == null && getActivity() != null) {
             final TypedValue tv = new TypedValue();
             getActivity().getTheme().resolveAttribute(
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackEditTextPreferenceDialogFragmentCompat.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackEditTextPreferenceDialogFragmentCompat.java
index e216741..27ed9cd 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackEditTextPreferenceDialogFragmentCompat.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackEditTextPreferenceDialogFragmentCompat.java
@@ -31,10 +31,11 @@
 import android.widget.EditText;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.preference.DialogPreference;
 import androidx.preference.EditTextPreference;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Implemented a dialog to input text.
  */
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragment.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragment.java
index 73df627..71093a4 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragment.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragment.java
@@ -27,8 +27,6 @@
 import android.widget.Checkable;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.collection.ArraySet;
 import androidx.leanback.widget.VerticalGridView;
 import androidx.preference.DialogPreference;
@@ -36,6 +34,9 @@
 import androidx.preference.MultiSelectListPreference;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragmentCompat.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragmentCompat.java
index 5dbf27d..1fcd204 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragmentCompat.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackListPreferenceDialogFragmentCompat.java
@@ -27,7 +27,6 @@
 import android.widget.Checkable;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.collection.ArraySet;
 import androidx.leanback.widget.VerticalGridView;
 import androidx.preference.DialogPreference;
@@ -35,6 +34,8 @@
 import androidx.preference.MultiSelectListPreference;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackPreferenceFragmentCompat.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackPreferenceFragmentCompat.java
index ae5ce2b..14a4a96 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackPreferenceFragmentCompat.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackPreferenceFragmentCompat.java
@@ -22,7 +22,7 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This fragment provides a fully decorated leanback-style preference fragment, including a
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragment.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragment.java
index 4f77893..5874e33 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragment.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragment.java
@@ -28,8 +28,6 @@
 import android.view.ViewGroup;
 import android.widget.Space;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.preference.ListPreference;
 import androidx.preference.MultiSelectListPreference;
@@ -37,6 +35,9 @@
 import androidx.preference.PreferenceFragment;
 import androidx.preference.PreferenceScreen;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * This fragment provides a container for displaying a {@link LeanbackPreferenceFragment}
  *
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragmentCompat.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragmentCompat.java
index c1c1d69..9bb0a6c 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragmentCompat.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsFragmentCompat.java
@@ -22,7 +22,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.preference.EditTextPreference;
@@ -33,6 +32,8 @@
 import androidx.preference.PreferenceFragmentCompat;
 import androidx.preference.PreferenceScreen;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * This fragment provides a container for displaying a {@link LeanbackPreferenceFragmentCompat}
  *
diff --git a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsRootView.java b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsRootView.java
index ddfbf31..e6582be 100644
--- a/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsRootView.java
+++ b/leanback/leanback-preference/src/main/java/androidx/leanback/preference/LeanbackSettingsRootView.java
@@ -23,9 +23,10 @@
 import android.view.KeyEvent;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  */
 @RestrictTo(LIBRARY_GROUP_PREFIX)
diff --git a/leanback/leanback-tab/build.gradle b/leanback/leanback-tab/build.gradle
index c490406..9ea6215 100644
--- a/leanback/leanback-tab/build.gradle
+++ b/leanback/leanback-tab/build.gradle
@@ -13,6 +13,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     api("com.google.android.material:material:1.0.0")
     api("androidx.viewpager:viewpager:1.0.0")
diff --git a/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TabLayoutTestActivity.java b/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TabLayoutTestActivity.java
index a944254..24a497d 100644
--- a/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TabLayoutTestActivity.java
+++ b/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TabLayoutTestActivity.java
@@ -18,7 +18,6 @@
 
 import android.os.Bundle;
 
-
 import androidx.fragment.app.FragmentActivity;
 import androidx.leanback.tab.LeanbackViewPager;
 import androidx.leanback.tab.test.R;
diff --git a/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TestFragment.java b/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TestFragment.java
index 36e9f4b..6dfba99 100644
--- a/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TestFragment.java
+++ b/leanback/leanback-tab/src/androidTest/java/androidx/leanback/tab/app/TestFragment.java
@@ -20,16 +20,16 @@
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.tab.test.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 public class TestFragment extends Fragment {
 
-    @Nullable
     @Override
-    public android.view.View onCreateView(@NonNull LayoutInflater inflater,
+    public android.view.@Nullable View onCreateView(@NonNull LayoutInflater inflater,
             @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         super.onCreateView(inflater, container, savedInstanceState);
         return inflater.inflate(R.layout.test_fragment, container, false);
diff --git a/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackTabLayout.java b/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackTabLayout.java
index 089c1cd..a9e3eaa 100644
--- a/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackTabLayout.java
+++ b/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackTabLayout.java
@@ -23,12 +23,13 @@
 import android.view.View;
 import android.widget.LinearLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.viewpager.widget.ViewPager;
 
 import com.google.android.material.tabs.TabLayout;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
diff --git a/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackViewPager.java b/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackViewPager.java
index bf85ed7..4b0fdc0 100644
--- a/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackViewPager.java
+++ b/leanback/leanback-tab/src/main/java/androidx/leanback/tab/LeanbackViewPager.java
@@ -21,10 +21,11 @@
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.viewpager.widget.ViewPager;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A viewpager with touch and key event handling disabled by default.
  *
diff --git a/leanback/leanback/build.gradle b/leanback/leanback/build.gradle
index 2195bb6..9804e69 100644
--- a/leanback/leanback/build.gradle
+++ b/leanback/leanback/build.gradle
@@ -13,6 +13,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api("androidx.annotation:annotation:1.8.1")
     api("androidx.interpolator:interpolator:1.0.0")
     api("androidx.core:core:1.1.0")
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTest.java
index 2a1b7db..a785fcf 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTest.java
@@ -23,6 +23,7 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
+import android.app.Fragment;
 import android.app.Instrumentation;
 import android.content.Context;
 import android.content.Intent;
@@ -38,9 +39,6 @@
 import android.widget.EditText;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
 import androidx.leanback.testutils.PollingCheck;
@@ -58,6 +56,8 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Rule;
 import org.junit.Test;
@@ -209,10 +209,9 @@
         BrowseFragment.MainFragmentAdapter<MyFragment> mMainFragmentAdapter =
                 new BrowseFragment.MainFragmentAdapter<>(this);
 
-        @Nullable
         @Override
-        public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-                @Nullable Bundle savedInstanceState) {
+        public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+                @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
             return new FrameLayout(container.getContext());
         }
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTestActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTestActivity.java
index c12af8e..dff2a16 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTestActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseFragmentTestActivity.java
@@ -18,11 +18,11 @@
  */
 package androidx.leanback.app;
 
+import android.app.Activity;
+import android.app.FragmentTransaction;
 import android.content.Intent;
 import android.os.Bundle;
 
-import android.app.Activity;
-import android.app.FragmentTransaction;
 import androidx.leanback.test.R;
 
 public class BrowseFragmentTestActivity extends Activity {
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java
index 07a16db..7cd4214 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java
@@ -35,8 +35,6 @@
 import android.widget.EditText;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
@@ -55,6 +53,8 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Rule;
 import org.junit.Test;
@@ -206,10 +206,9 @@
         BrowseSupportFragment.MainFragmentAdapter<MyFragment> mMainFragmentAdapter =
                 new BrowseSupportFragment.MainFragmentAdapter<>(this);
 
-        @Nullable
         @Override
-        public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-                @Nullable Bundle savedInstanceState) {
+        public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+                @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
             return new FrameLayout(container.getContext());
         }
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsFragmentTest.java
index 7893821..ed95ca0 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsFragmentTest.java
@@ -26,6 +26,7 @@
 import static org.junit.Assert.assertTrue;
 
 import android.animation.PropertyValuesHolder;
+import android.app.Fragment;
 import android.app.UiAutomation;
 import android.content.Context;
 import android.content.Intent;
@@ -45,8 +46,6 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
 
-import androidx.annotation.NonNull;
-import android.app.Fragment;
 import androidx.leanback.graphics.FitWidthBitmapDrawable;
 import androidx.leanback.media.MediaPlayerGlue;
 import androidx.leanback.media.PlaybackGlueHost;
@@ -66,6 +65,7 @@
 import androidx.test.filters.SdkSuppress;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsSupportFragmentTest.java
index 1db3f5e..697ffd2 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsSupportFragmentTest.java
@@ -42,7 +42,6 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
 
-import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.graphics.FitWidthBitmapDrawable;
 import androidx.leanback.media.MediaPlayerGlue;
@@ -63,6 +62,7 @@
 import androidx.test.filters.SdkSuppress;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestFragment.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestFragment.java
index 13e5bcd..3e20de0 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestFragment.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestFragment.java
@@ -23,8 +23,6 @@
 import android.os.Handler;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.test.R;
 import androidx.leanback.widget.AbstractDetailsDescriptionPresenter;
 import androidx.leanback.widget.Action;
@@ -39,6 +37,9 @@
 import androidx.leanback.widget.Presenter;
 import androidx.leanback.widget.SparseArrayObjectAdapter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Base class provides overview row and some related rows.
  */
@@ -47,9 +48,8 @@
     private ArrayObjectAdapter mRowsAdapter;
     private PhotoItem mPhotoItem;
     private final Presenter mCardPresenter = new Presenter() {
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
             ImageCardView cardView = new ImageCardView(getActivity());
             cardView.setFocusable(true);
             cardView.setFocusableInTouchMode(true);
@@ -94,7 +94,7 @@
                 new FullWidthDetailsOverviewRowPresenter(new AbstractDetailsDescriptionPresenter() {
                     @Override
                     protected void onBindDescription(
-                            @NonNull AbstractDetailsDescriptionPresenter.ViewHolder vh,
+                            AbstractDetailsDescriptionPresenter.@NonNull ViewHolder vh,
                             @Nullable Object item
                     ) {
                         vh.getTitle().setText("Funny Movie");
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestSupportFragment.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestSupportFragment.java
index 7810eea..9be2075 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestSupportFragment.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/DetailsTestSupportFragment.java
@@ -20,8 +20,6 @@
 import android.os.Handler;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.test.R;
 import androidx.leanback.widget.AbstractDetailsDescriptionPresenter;
 import androidx.leanback.widget.Action;
@@ -36,6 +34,9 @@
 import androidx.leanback.widget.Presenter;
 import androidx.leanback.widget.SparseArrayObjectAdapter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Base class provides overview row and some related rows.
  */
@@ -44,9 +45,8 @@
     private ArrayObjectAdapter mRowsAdapter;
     private PhotoItem mPhotoItem;
     private final Presenter mCardPresenter = new Presenter() {
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
             ImageCardView cardView = new ImageCardView(getActivity());
             cardView.setFocusable(true);
             cardView.setFocusableInTouchMode(true);
@@ -91,7 +91,7 @@
                 new FullWidthDetailsOverviewRowPresenter(new AbstractDetailsDescriptionPresenter() {
                     @Override
                     protected void onBindDescription(
-                            @NonNull AbstractDetailsDescriptionPresenter.ViewHolder vh,
+                            AbstractDetailsDescriptionPresenter.@NonNull ViewHolder vh,
                             @Nullable Object item
                     ) {
                         vh.getTitle().setText("Funny Movie");
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepFragmentTestActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepFragmentTestActivity.java
index e4c1171..1a28995 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepFragmentTestActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepFragmentTestActivity.java
@@ -17,11 +17,10 @@
 
 package androidx.leanback.app;
 
+import android.app.Activity;
 import android.content.Intent;
 import android.os.Bundle;
 
-import android.app.Activity;
-
 public class GuidedStepFragmentTestActivity extends Activity {
 
     /**
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestFragment.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestFragment.java
index a8b9010..b506c9f 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestFragment.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestFragment.java
@@ -17,17 +17,18 @@
 
 package androidx.leanback.app;
 
+import android.app.Activity;
+import android.app.FragmentManager;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import android.app.Activity;
-import android.app.FragmentManager;
 import androidx.leanback.widget.GuidanceStylist.Guidance;
 import androidx.leanback.widget.GuidedAction;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.List;
 
@@ -152,9 +153,8 @@
         mProvider.onSaveInstanceState(outState);
     }
 
-    @NonNull
     @Override
-    public Guidance onCreateGuidance(@NonNull Bundle savedInstanceState) {
+    public @NonNull Guidance onCreateGuidance(@NonNull Bundle savedInstanceState) {
         Guidance g = mProvider.onCreateGuidance(savedInstanceState);
         if (g == null) {
             g = new Guidance("", "", "", null);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestSupportFragment.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestSupportFragment.java
index f5175c7..418c585 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestSupportFragment.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/GuidedStepTestSupportFragment.java
@@ -19,12 +19,13 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
 import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentManager;
 import androidx.leanback.widget.GuidanceStylist.Guidance;
 import androidx.leanback.widget.GuidedAction;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.List;
 
@@ -149,9 +150,8 @@
         mProvider.onSaveInstanceState(outState);
     }
 
-    @NonNull
     @Override
-    public Guidance onCreateGuidance(@NonNull Bundle savedInstanceState) {
+    public @NonNull Guidance onCreateGuidance(@NonNull Bundle savedInstanceState) {
         Guidance g = mProvider.onCreateGuidance(savedInstanceState);
         if (g == null) {
             g = new Guidance("", "", "", null);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/ListRowDataAdapterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/ListRowDataAdapterTest.java
index d0a203b6..78d1a2e 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/ListRowDataAdapterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/ListRowDataAdapterTest.java
@@ -29,7 +29,6 @@
 import androidx.leanback.widget.SectionRow;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
-import androidx.test.filters.SmallTest;
 
 import org.junit.Before;
 import org.junit.Test;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackFragmentTest.java
index 37b9400..1762c1e 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackFragmentTest.java
@@ -33,8 +33,6 @@
 import android.view.KeyEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.media.PlaybackControlGlue;
 import androidx.leanback.media.PlaybackGlue;
 import androidx.leanback.media.PlaybackGlueHost;
@@ -56,6 +54,8 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.Suppress;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackSupportFragmentTest.java
index 9e930c4..20be774 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/PlaybackSupportFragmentTest.java
@@ -30,8 +30,6 @@
 import android.view.KeyEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.media.PlaybackControlGlue;
 import androidx.leanback.media.PlaybackGlue;
 import androidx.leanback.media.PlaybackGlueHost;
@@ -53,6 +51,8 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.Suppress;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsFragmentTest.java
index 0a84e01..894ce4a 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsFragmentTest.java
@@ -26,6 +26,7 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
+import android.app.Fragment;
 import android.content.Context;
 import android.graphics.Rect;
 import android.os.Build;
@@ -40,8 +41,6 @@
 import android.widget.EditText;
 import android.widget.TextView;
 
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
 import androidx.leanback.testutils.PollingCheck;
@@ -65,6 +64,7 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.testutils.AnimationTest;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsSupportFragmentTest.java
index 631c3de..67b4e68 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/RowsSupportFragmentTest.java
@@ -37,7 +37,6 @@
 import android.widget.EditText;
 import android.widget.TextView;
 
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
@@ -62,6 +61,7 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.testutils.AnimationTest;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchFragmentTest.java
index d671155..ddd7174 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchFragmentTest.java
@@ -20,6 +20,7 @@
 
 import static org.junit.Assert.assertTrue;
 
+import android.app.Fragment;
 import android.content.Context;
 import android.os.Build;
 import android.os.Bundle;
@@ -31,7 +32,6 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
 
-import android.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
 import androidx.leanback.testutils.PollingCheck;
@@ -47,11 +47,12 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.testutils.AnimationTest;
 
-import java.util.Objects;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.Objects;
+
 @LargeTest
 @AnimationTest
 @RunWith(AndroidJUnit4.class)
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchSupportFragmentTest.java
index 8be9af1..41e7aea 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SearchSupportFragmentTest.java
@@ -44,11 +44,12 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.testutils.AnimationTest;
 
-import java.util.Objects;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.Objects;
+
 @LargeTest
 @AnimationTest
 @RunWith(AndroidJUnit4.class)
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SingleFragmentTestActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SingleFragmentTestActivity.java
index 2f3d994..2a3d328 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/SingleFragmentTestActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/SingleFragmentTestActivity.java
@@ -18,13 +18,13 @@
  */
 package androidx.leanback.app;
 
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentTransaction;
 import android.content.Intent;
 import android.os.Bundle;
 import android.util.Log;
 
-import android.app.Fragment;
-import android.app.Activity;
-import android.app.FragmentTransaction;
 import androidx.leanback.test.R;
 
 public class SingleFragmentTestActivity extends Activity {
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/StringPresenter.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/StringPresenter.java
index 89c8c72..8166e71 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/StringPresenter.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/StringPresenter.java
@@ -18,17 +18,17 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.widget.Presenter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 public class StringPresenter extends Presenter {
     private static final boolean DEBUG = false;
     private static final String TAG = "StringPresenter";
 
-    @NonNull
     @Override
-    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         if (DEBUG) Log.d(TAG, "onCreateViewHolder");
         TextView tv = new TextView(parent.getContext());
         tv.setFocusable(true);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VerticalGridFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VerticalGridFragmentTest.java
index 632ab55..391af4e 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VerticalGridFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VerticalGridFragmentTest.java
@@ -19,6 +19,7 @@
 
 package androidx.leanback.app;
 
+import android.app.Fragment;
 import android.content.Context;
 import android.os.Build;
 import android.os.Bundle;
@@ -28,7 +29,6 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
 
-import android.app.Fragment;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.LeakDetector;
 import androidx.leanback.testutils.PollingCheck;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoFragmentTest.java
index 36d4d2d..530df20 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoFragmentTest.java
@@ -32,7 +32,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.media.MediaPlayerGlue;
 import androidx.leanback.media.PlaybackGlue;
 import androidx.leanback.media.PlaybackGlueHost;
@@ -42,6 +41,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoSupportFragmentTest.java
index e67fcb6..0985a5f 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoSupportFragmentTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/VideoSupportFragmentTest.java
@@ -29,7 +29,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.media.MediaPlayerGlue;
 import androidx.leanback.media.PlaybackGlue;
 import androidx.leanback.media.PlaybackGlueHost;
@@ -39,6 +38,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/wizard/GuidedStepAttributesTestFragment.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/wizard/GuidedStepAttributesTestFragment.java
index 292b542..96a6ed4 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/wizard/GuidedStepAttributesTestFragment.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/wizard/GuidedStepAttributesTestFragment.java
@@ -16,11 +16,12 @@
 
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.app.GuidedStepFragment;
 import androidx.leanback.widget.GuidanceStylist;
 import androidx.leanback.widget.GuidedAction;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.List;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaControllerAdapterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaControllerAdapterTest.java
index 16b52b0..407f854 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaControllerAdapterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaControllerAdapterTest.java
@@ -34,14 +34,14 @@
 import android.support.v4.media.session.MediaSessionCompat;
 import android.support.v4.media.session.PlaybackStateCompat;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.widget.PlaybackControlsRow;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaPlayerGlueTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaPlayerGlueTest.java
index 7f6fa9e..9b51de0 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaPlayerGlueTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/media/MediaPlayerGlueTest.java
@@ -26,13 +26,13 @@
 import android.os.Build;
 import android.os.SystemClock;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.testutils.PollingCheck;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mockito;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/media/PlaybackGlueTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/media/PlaybackGlueTest.java
index d0a7858..d548aa10 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/media/PlaybackGlueTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/media/PlaybackGlueTest.java
@@ -27,11 +27,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mockito;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridActivity.java
index cfc1991..d56c1fe 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridActivity.java
@@ -26,12 +26,13 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.test.R;
 import androidx.recyclerview.widget.ConcatAdapter;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.reflect.InvocationTargetException;
 import java.util.ArrayList;
 
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
index f182de2..682c34f 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
@@ -49,8 +49,6 @@
 import android.view.animation.Interpolator;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.PollingCheck;
@@ -65,6 +63,8 @@
 import androidx.testutils.AnimationActivityTestRule;
 import androidx.testutils.AnimationTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Ignore;
 import org.junit.Rule;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ItemBridgeAdapterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ItemBridgeAdapterTest.java
index 9e0517b..c842a84 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ItemBridgeAdapterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ItemBridgeAdapterTest.java
@@ -27,14 +27,14 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -51,9 +51,8 @@
 
     public static class BasePresenter extends Presenter {
 
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View view = new View(parent.getContext());
             view.setLayoutParams(new ViewGroup.LayoutParams(sViewWidth, sViewHeight));
             return new ViewHolder(view);
@@ -157,9 +156,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
             View view = mViews.get(mViews.size() - 1);
             mViews.remove(mViews.size() - 1);
             view.setLayoutParams(new ViewGroup.LayoutParams(sViewWidth, sViewHeight));
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ListRowPresenterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ListRowPresenterTest.java
index 496b8aab..8d9c64d 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ListRowPresenterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ListRowPresenterTest.java
@@ -29,14 +29,14 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -62,9 +62,8 @@
             mHeight = height;
         }
 
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View view = new View(parent.getContext());
             view.setFocusable(true);
             view.setId(R.id.lb_action_button);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ObjectAdapterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ObjectAdapterTest.java
index 7598697..b552b70 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ObjectAdapterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/ObjectAdapterTest.java
@@ -31,14 +31,14 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -175,9 +175,8 @@
             return oldItem.equals(newItem);
         }
 
-        @Nullable
         @Override
-        public Object getChangePayload(@NonNull AdapterItem oldItem,
+        public @Nullable Object getChangePayload(@NonNull AdapterItem oldItem,
                 @NonNull AdapterItem newItem) {
             Bundle diff = new Bundle();
             if (oldItem.getId() != newItem.getId()) {
@@ -222,9 +221,8 @@
             mHeight = height;
         }
 
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View view = new View(parent.getContext());
             view.setFocusable(true);
             view.setId(R.id.lb_action_button);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PlaybackTransportRowPresenterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PlaybackTransportRowPresenterTest.java
index 9cb7606..f640029 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PlaybackTransportRowPresenterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PlaybackTransportRowPresenterTest.java
@@ -36,7 +36,6 @@
 import android.view.View;
 import android.view.ViewParent;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.media.PlaybackTransportControlGlue;
 import androidx.leanback.media.PlayerAdapter;
 import androidx.leanback.widget.PlaybackSeekDataProvider.ResultCallback;
@@ -44,6 +43,7 @@
 import androidx.test.filters.SdkSuppress;
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.Mockito;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PresenterTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PresenterTest.java
index 554eaef..716acc4 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PresenterTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/PresenterTest.java
@@ -27,14 +27,14 @@
 import android.view.ViewGroup.LayoutParams;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.app.HeadersFragment;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/TestPresenter.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/TestPresenter.java
index 4b30e10..8543178 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/TestPresenter.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/TestPresenter.java
@@ -20,8 +20,8 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -116,9 +116,8 @@
         return newList;
     }
 
-    @NonNull
     @Override
-    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         TextView tv = new TextView(parent.getContext());
         tv.setFocusable(true);
         tv.setFocusableInTouchMode(true);
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/DatePickerActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/DatePickerActivity.java
index f7f49d5..89976ca 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/DatePickerActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/DatePickerActivity.java
@@ -21,7 +21,6 @@
 
 import androidx.leanback.test.R;
 
-
 public class DatePickerActivity extends Activity {
 
     public static final String EXTRA_LAYOUT_RESOURCE_ID = "layoutResourceId";
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/PinPickerTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/PinPickerTest.java
index f22cfd4..8123147 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/PinPickerTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/PinPickerTest.java
@@ -25,7 +25,6 @@
 import android.view.KeyEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.test.R;
 import androidx.leanback.testutils.PollingCheck;
 import androidx.recyclerview.widget.RecyclerView;
@@ -33,6 +32,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.ActivityTestRule;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/TimePickerActivity.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/TimePickerActivity.java
index da5eb0d..fa136f2 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/TimePickerActivity.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/picker/TimePickerActivity.java
@@ -21,7 +21,6 @@
 
 import androidx.leanback.test.R;
 
-
 public class TimePickerActivity extends Activity {
 
     public static final String EXTRA_LAYOUT_RESOURCE_ID = "layoutResourceId";
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BackgroundManager.java b/leanback/leanback/src/main/java/androidx/leanback/app/BackgroundManager.java
index 3fc4c83..0a63418 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BackgroundManager.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BackgroundManager.java
@@ -37,12 +37,13 @@
 import android.view.animation.Interpolator;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.core.content.ContextCompat;
 import androidx.core.graphics.drawable.DrawableCompat;
 import androidx.interpolator.view.animation.FastOutLinearInInterpolator;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.ref.WeakReference;
 
 /**
@@ -209,9 +210,8 @@
             return mState;
         }
 
-        @NonNull
         @Override
-        public Drawable mutate() {
+        public @NonNull Drawable mutate() {
             if (!mMutated) {
                 mMutated = true;
                 mState = new ConstantState(mState);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BaseFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BaseFragment.java
index 7b50326..0aadb6f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BaseFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BaseFragment.java
@@ -21,8 +21,6 @@
 import android.view.View;
 import android.view.ViewTreeObserver;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.transition.TransitionListener;
 import androidx.leanback.util.StateMachine;
@@ -30,6 +28,9 @@
 import androidx.leanback.util.StateMachine.Event;
 import androidx.leanback.util.StateMachine.State;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Base class for leanback Fragments. This class is not intended to be subclassed by apps.
  * @deprecated use {@link BaseSupportFragment}
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowFragment.java
index 873c50a..584bd2c 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowFragment.java
@@ -16,14 +16,12 @@
  */
 package androidx.leanback.app;
 
+import android.app.Fragment;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.widget.ItemBridgeAdapter;
 import androidx.leanback.widget.ListRow;
 import androidx.leanback.widget.ObjectAdapter;
@@ -33,6 +31,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An internal base class for a fragment containing a list of rows.
  * @deprecated use {@link BaseRowSupportFragment}
@@ -67,8 +68,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         View view = inflater.inflate(getLayoutResourceId(), container, false);
         mVerticalGridView = findGridViewFromRoot(view);
         if (mPendingTransitionPrepare) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowSupportFragment.java
index de9ab85..a76748d 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BaseRowSupportFragment.java
@@ -18,8 +18,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.widget.ItemBridgeAdapter;
 import androidx.leanback.widget.ListRow;
@@ -30,6 +28,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An internal base class for a fragment containing a list of rows.
  */
@@ -62,9 +63,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         View view = inflater.inflate(getLayoutResourceId(), container, false);
         mVerticalGridView = findGridViewFromRoot(view);
         if (mPendingTransitionPrepare) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BaseSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BaseSupportFragment.java
index 1798d4d2..c05382c 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BaseSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BaseSupportFragment.java
@@ -18,8 +18,6 @@
 import android.view.View;
 import android.view.ViewTreeObserver;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.transition.TransitionListener;
 import androidx.leanback.util.StateMachine;
@@ -27,6 +25,9 @@
 import androidx.leanback.util.StateMachine.Event;
 import androidx.leanback.util.StateMachine.State;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Base class for leanback Fragments. This class is not intended to be subclassed by apps.
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BrandedFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BrandedFragment.java
index 9808ba9f..da43a4d 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BrandedFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BrandedFragment.java
@@ -16,6 +16,7 @@
  */
 package androidx.leanback.app;
 
+import android.app.Fragment;
 import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 import android.util.TypedValue;
@@ -23,14 +24,14 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.SearchOrbView;
 import androidx.leanback.widget.TitleHelper;
 import androidx.leanback.widget.TitleViewAdapter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Fragment class for managing search and branding using a view that implements
  * {@link TitleViewAdapter.Provider}.
@@ -67,9 +68,8 @@
      * @return Title view which must have a descendant with id browse_title_group that implements
      * {@link TitleViewAdapter.Provider}, or null for no title view.
      */
-    @NonNull
-    public View onInflateTitleView(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent,
-            @Nullable Bundle savedInstanceState) {
+    public @NonNull View onInflateTitleView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup parent, @Nullable Bundle savedInstanceState) {
         TypedValue typedValue = new TypedValue();
         boolean found = parent != null && parent.getContext().getTheme().resolveAttribute(
                 R.attr.browseTitleViewLayout, typedValue, true);
@@ -126,8 +126,7 @@
      * Returns the view that implements {@link TitleViewAdapter.Provider}.
      * @return The view that implements {@link TitleViewAdapter.Provider}.
      */
-    @Nullable
-    public View getTitleView() {
+    public @Nullable View getTitleView() {
         return mTitleView;
     }
 
@@ -135,8 +134,7 @@
      * Returns the {@link TitleViewAdapter} implemented by title view.
      * @return The {@link TitleViewAdapter} implemented by title view.
      */
-    @Nullable
-    public TitleViewAdapter getTitleViewAdapter() {
+    public @Nullable TitleViewAdapter getTitleViewAdapter() {
         return mTitleViewAdapter;
     }
 
@@ -221,8 +219,7 @@
      * Returns the badge drawable used in the fragment title.
      * @return The badge drawable used in the fragment title.
      */
-    @Nullable
-    public Drawable getBadgeDrawable() {
+    public @Nullable Drawable getBadgeDrawable() {
         return mBadgeDrawable;
     }
 
@@ -242,8 +239,7 @@
      * Returns the title text for the fragment.
      * @return Title text for the fragment.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         return mTitle;
     }
 
@@ -259,7 +255,7 @@
      *
      * @param listener The listener to call when the search element is clicked.
      */
-    public void setOnSearchClickedListener(@Nullable View.OnClickListener listener) {
+    public void setOnSearchClickedListener(View.@Nullable OnClickListener listener) {
         mExternalOnSearchClickedListener = listener;
         if (mTitleViewAdapter != null) {
             mTitleViewAdapter.setOnSearchClickedListener(listener);
@@ -272,7 +268,7 @@
      *
      * @param colors Colors used to draw search affordance.
      */
-    public void setSearchAffordanceColors(@NonNull SearchOrbView.Colors colors) {
+    public void setSearchAffordanceColors(SearchOrbView.@NonNull Colors colors) {
         mSearchAffordanceColors = colors;
         mSearchAffordanceColorSet = true;
         if (mTitleViewAdapter != null) {
@@ -284,8 +280,7 @@
      * Returns the {@link androidx.leanback.widget.SearchOrbView.Colors}
      * used to draw the search affordance.
      */
-    @Nullable
-    public SearchOrbView.Colors getSearchAffordanceColors() {
+    public SearchOrbView.@Nullable Colors getSearchAffordanceColors() {
         if (mSearchAffordanceColorSet) {
             return mSearchAffordanceColors;
         }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BrandedSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BrandedSupportFragment.java
index 7222916..58aba27 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BrandedSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BrandedSupportFragment.java
@@ -20,14 +20,15 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.SearchOrbView;
 import androidx.leanback.widget.TitleHelper;
 import androidx.leanback.widget.TitleViewAdapter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Fragment class for managing search and branding using a view that implements
  * {@link TitleViewAdapter.Provider}.
@@ -62,9 +63,8 @@
      * @return Title view which must have a descendant with id browse_title_group that implements
      * {@link TitleViewAdapter.Provider}, or null for no title view.
      */
-    @NonNull
-    public View onInflateTitleView(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent,
-            @Nullable Bundle savedInstanceState) {
+    public @NonNull View onInflateTitleView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup parent, @Nullable Bundle savedInstanceState) {
         TypedValue typedValue = new TypedValue();
         boolean found = parent != null && parent.getContext().getTheme().resolveAttribute(
                 R.attr.browseTitleViewLayout, typedValue, true);
@@ -121,8 +121,7 @@
      * Returns the view that implements {@link TitleViewAdapter.Provider}.
      * @return The view that implements {@link TitleViewAdapter.Provider}.
      */
-    @Nullable
-    public View getTitleView() {
+    public @Nullable View getTitleView() {
         return mTitleView;
     }
 
@@ -130,8 +129,7 @@
      * Returns the {@link TitleViewAdapter} implemented by title view.
      * @return The {@link TitleViewAdapter} implemented by title view.
      */
-    @Nullable
-    public TitleViewAdapter getTitleViewAdapter() {
+    public @Nullable TitleViewAdapter getTitleViewAdapter() {
         return mTitleViewAdapter;
     }
 
@@ -216,8 +214,7 @@
      * Returns the badge drawable used in the fragment title.
      * @return The badge drawable used in the fragment title.
      */
-    @Nullable
-    public Drawable getBadgeDrawable() {
+    public @Nullable Drawable getBadgeDrawable() {
         return mBadgeDrawable;
     }
 
@@ -237,8 +234,7 @@
      * Returns the title text for the fragment.
      * @return Title text for the fragment.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         return mTitle;
     }
 
@@ -254,7 +250,7 @@
      *
      * @param listener The listener to call when the search element is clicked.
      */
-    public void setOnSearchClickedListener(@Nullable View.OnClickListener listener) {
+    public void setOnSearchClickedListener(View.@Nullable OnClickListener listener) {
         mExternalOnSearchClickedListener = listener;
         if (mTitleViewAdapter != null) {
             mTitleViewAdapter.setOnSearchClickedListener(listener);
@@ -267,7 +263,7 @@
      *
      * @param colors Colors used to draw search affordance.
      */
-    public void setSearchAffordanceColors(@NonNull SearchOrbView.Colors colors) {
+    public void setSearchAffordanceColors(SearchOrbView.@NonNull Colors colors) {
         mSearchAffordanceColors = colors;
         mSearchAffordanceColorSet = true;
         if (mTitleViewAdapter != null) {
@@ -279,8 +275,7 @@
      * Returns the {@link androidx.leanback.widget.SearchOrbView.Colors}
      * used to draw the search affordance.
      */
-    @Nullable
-    public SearchOrbView.Colors getSearchAffordanceColors() {
+    public SearchOrbView.@Nullable Colors getSearchAffordanceColors() {
         if (mSearchAffordanceColorSet) {
             return mSearchAffordanceColors;
         }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BrowseFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BrowseFragment.java
index 6901095..ee6b4a6 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BrowseFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BrowseFragment.java
@@ -18,6 +18,11 @@
 
 import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
 
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentManager.BackStackEntry;
+import android.app.FragmentTransaction;
 import android.content.Context;
 import android.content.res.TypedArray;
 import android.graphics.Color;
@@ -31,14 +36,6 @@
 import android.view.ViewTreeObserver;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.view.ViewCompat;
-import android.app.Fragment;
-import android.app.Activity;
-import android.app.FragmentManager;
-import android.app.FragmentManager.BackStackEntry;
-import android.app.FragmentTransaction;
 import androidx.leanback.R;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.transition.TransitionListener;
@@ -61,6 +58,8 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.Nullable;
+
 import java.util.HashMap;
 import java.util.Map;
 
@@ -1269,8 +1268,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater,@Nullable ViewGroup container,
+            Bundle savedInstanceState) {
 
         if (getChildFragmentManager().findFragmentById(R.id.scale_frame) == null) {
             mHeadersFragment = onCreateHeadersFragment();
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/BrowseSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/BrowseSupportFragment.java
index 3753ec5..ea377f8 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/BrowseSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/BrowseSupportFragment.java
@@ -28,8 +28,6 @@
 import android.view.ViewTreeObserver;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentManager;
@@ -57,6 +55,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.HashMap;
 import java.util.Map;
 
@@ -1244,9 +1245,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
 
         if (getChildFragmentManager().findFragmentById(R.id.scale_frame) == null) {
             mHeadersSupportFragment = onCreateHeadersSupportFragment();
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsBackgroundVideoHelper.java b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsBackgroundVideoHelper.java
index caf176ff..b06b151 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsBackgroundVideoHelper.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsBackgroundVideoHelper.java
@@ -20,13 +20,14 @@
 import android.animation.ValueAnimator;
 import android.graphics.drawable.Drawable;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.media.PlaybackGlue;
 import androidx.leanback.widget.DetailsParallax;
 import androidx.leanback.widget.Parallax;
 import androidx.leanback.widget.ParallaxEffect;
 import androidx.leanback.widget.ParallaxTarget;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper class responsible for controlling video playback in {@link DetailsFragment}. This
  * takes {@link DetailsParallax}, {@link PlaybackGlue} and a drawable as input.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragment.java
index 2d78042..3ae0336 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragment.java
@@ -19,6 +19,9 @@
  */
 package androidx.leanback.app;
 
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentTransaction;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.os.Build;
@@ -31,11 +34,6 @@
 import android.view.Window;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
-import android.app.Activity;
-import android.app.FragmentTransaction;
 import androidx.leanback.R;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.transition.TransitionListener;
@@ -54,6 +52,9 @@
 import androidx.leanback.widget.RowPresenter;
 import androidx.leanback.widget.VerticalGridView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 
 /**
@@ -479,8 +480,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         mRootView = (BrowseFrameLayout) inflater.inflate(
                 R.layout.lb_details_fragment, container, false);
         mBackgroundView = mRootView.findViewById(R.id.details_background_view);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragmentBackgroundController.java b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragmentBackgroundController.java
index 144d355..a4933e9 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragmentBackgroundController.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsFragmentBackgroundController.java
@@ -19,15 +19,13 @@
 package androidx.leanback.app;
 
 import android.animation.PropertyValuesHolder;
+import android.app.Fragment;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.drawable.ColorDrawable;
 import android.graphics.drawable.Drawable;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.graphics.FitWidthBitmapDrawable;
 import androidx.leanback.media.PlaybackGlue;
@@ -35,6 +33,9 @@
 import androidx.leanback.widget.DetailsParallaxDrawable;
 import androidx.leanback.widget.ParallaxTarget;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Controller for DetailsFragment parallax background and embedded video play.
  * <p>
@@ -189,7 +190,7 @@
      * @throws IllegalStateException If {@link #setupVideoPlayback(PlaybackGlue)} was called.
      */
     public void enableParallax(@NonNull Drawable coverDrawable, @NonNull Drawable bottomDrawable,
-                               @Nullable ParallaxTarget.PropertyValuesHolderTarget
+                               ParallaxTarget.@Nullable PropertyValuesHolderTarget
                                        coverDrawableParallaxTarget) {
         if (mParallaxDrawable != null) {
             return;
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragment.java
index 2b97bfb..3ee572b2 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragment.java
@@ -28,8 +28,6 @@
 import android.view.Window;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentTransaction;
@@ -51,6 +49,9 @@
 import androidx.leanback.widget.RowPresenter;
 import androidx.leanback.widget.VerticalGridView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 
 /**
@@ -474,9 +475,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         mRootView = (BrowseFrameLayout) inflater.inflate(
                 R.layout.lb_details_fragment, container, false);
         mBackgroundView = mRootView.findViewById(R.id.details_background_view);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragmentBackgroundController.java b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragmentBackgroundController.java
index cc32404..2b6127b 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragmentBackgroundController.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/DetailsSupportFragmentBackgroundController.java
@@ -22,8 +22,6 @@
 import android.graphics.drawable.Drawable;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.graphics.FitWidthBitmapDrawable;
@@ -32,6 +30,9 @@
 import androidx.leanback.widget.DetailsParallaxDrawable;
 import androidx.leanback.widget.ParallaxTarget;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Controller for DetailsSupportFragment parallax background and embedded video play.
  * <p>
@@ -184,7 +185,7 @@
      * @throws IllegalStateException If {@link #setupVideoPlayback(PlaybackGlue)} was called.
      */
     public void enableParallax(@NonNull Drawable coverDrawable, @NonNull Drawable bottomDrawable,
-                               @Nullable ParallaxTarget.PropertyValuesHolderTarget
+                               ParallaxTarget.@Nullable PropertyValuesHolderTarget
                                        coverDrawableParallaxTarget) {
         if (mParallaxDrawable != null) {
             return;
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/ErrorFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/ErrorFragment.java
index d887e5f..642a453 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/ErrorFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/ErrorFragment.java
@@ -29,10 +29,10 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for displaying an error indication.
  * @deprecated use {@link ErrorSupportFragment}
@@ -89,8 +89,7 @@
     /**
      * Returns the background drawable.  May be null if a default is used.
      */
-    @Nullable
-    public Drawable getBackgroundDrawable() {
+    public @Nullable Drawable getBackgroundDrawable() {
         return mBackgroundDrawable;
     }
 
@@ -107,8 +106,7 @@
     /**
      * Returns the drawable used for the error image.
      */
-    @Nullable
-    public Drawable getImageDrawable() {
+    public @Nullable Drawable getImageDrawable() {
         return mDrawable;
     }
 
@@ -125,8 +123,7 @@
     /**
      * Returns the error message.
      */
-    @Nullable
-    public CharSequence getMessage() {
+    public @Nullable CharSequence getMessage() {
         return mMessage;
     }
 
@@ -143,8 +140,7 @@
     /**
      * Returns the button text.
      */
-    @Nullable
-    public String getButtonText() {
+    public @Nullable String getButtonText() {
         return mButtonText;
     }
 
@@ -153,7 +149,7 @@
      *
      * @param clickListener The click listener for the button.
      */
-    public void setButtonClickListener(@Nullable View.OnClickListener clickListener) {
+    public void setButtonClickListener(View.@Nullable OnClickListener clickListener) {
         mButtonClickListener = clickListener;
         updateButton();
     }
@@ -161,14 +157,13 @@
     /**
      * Returns the button click listener.
      */
-    @Nullable
-    public View.OnClickListener getButtonClickListener() {
+    public View.@Nullable OnClickListener getButtonClickListener() {
         return mButtonClickListener;
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         View root = inflater.inflate(R.layout.lb_error_fragment, container, false);
 
         mErrorFrame = (ViewGroup) root.findViewById(R.id.error_frame);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/ErrorSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/ErrorSupportFragment.java
index e1afe1e..e0ba349 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/ErrorSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/ErrorSupportFragment.java
@@ -26,10 +26,11 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for displaying an error indication.
  */
@@ -84,8 +85,7 @@
     /**
      * Returns the background drawable.  May be null if a default is used.
      */
-    @Nullable
-    public Drawable getBackgroundDrawable() {
+    public @Nullable Drawable getBackgroundDrawable() {
         return mBackgroundDrawable;
     }
 
@@ -102,8 +102,7 @@
     /**
      * Returns the drawable used for the error image.
      */
-    @Nullable
-    public Drawable getImageDrawable() {
+    public @Nullable Drawable getImageDrawable() {
         return mDrawable;
     }
 
@@ -120,8 +119,7 @@
     /**
      * Returns the error message.
      */
-    @Nullable
-    public CharSequence getMessage() {
+    public @Nullable CharSequence getMessage() {
         return mMessage;
     }
 
@@ -138,8 +136,7 @@
     /**
      * Returns the button text.
      */
-    @Nullable
-    public String getButtonText() {
+    public @Nullable String getButtonText() {
         return mButtonText;
     }
 
@@ -148,7 +145,7 @@
      *
      * @param clickListener The click listener for the button.
      */
-    public void setButtonClickListener(@Nullable View.OnClickListener clickListener) {
+    public void setButtonClickListener(View.@Nullable OnClickListener clickListener) {
         mButtonClickListener = clickListener;
         updateButton();
     }
@@ -156,15 +153,13 @@
     /**
      * Returns the button click listener.
      */
-    @Nullable
-    public View.OnClickListener getButtonClickListener() {
+    public View.@Nullable OnClickListener getButtonClickListener() {
         return mButtonClickListener;
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         View root = inflater.inflate(R.layout.lb_error_fragment, container, false);
 
         mErrorFrame = (ViewGroup) root.findViewById(R.id.error_frame);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepFragment.java
index 031433f9..a80d629 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepFragment.java
@@ -20,6 +20,11 @@
 
 import android.animation.Animator;
 import android.animation.AnimatorSet;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentManager.BackStackEntry;
+import android.app.FragmentTransaction;
 import android.content.Context;
 import android.os.Build;
 import android.os.Bundle;
@@ -33,15 +38,8 @@
 import android.widget.FrameLayout;
 import android.widget.LinearLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.app.ActivityCompat;
-import android.app.Fragment;
-import android.app.Activity;
-import android.app.FragmentManager;
-import android.app.FragmentManager.BackStackEntry;
-import android.app.FragmentTransaction;
 import androidx.leanback.R;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.widget.DiffCallback;
@@ -54,6 +52,9 @@
 import androidx.leanback.widget.NonOverlappingLinearLayout;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -248,9 +249,8 @@
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     public static class DummyFragment extends Fragment {
-        @NonNull
         @Override
-        public View onCreateView(
+        public @NonNull View onCreateView(
                 @NonNull LayoutInflater inflater,
                 @Nullable ViewGroup container,
                 @Nullable Bundle savedInstanceState
@@ -282,8 +282,7 @@
      * a basic GuidanceStylist.
      * @return The GuidanceStylist used in this fragment.
      */
-    @NonNull
-    public GuidanceStylist onCreateGuidanceStylist() {
+    public @NonNull GuidanceStylist onCreateGuidanceStylist() {
         return new GuidanceStylist();
     }
 
@@ -292,8 +291,7 @@
      * returns a basic GuidedActionsStylist.
      * @return The GuidedActionsStylist used in this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist onCreateActionsStylist() {
+    public @NonNull GuidedActionsStylist onCreateActionsStylist() {
         return new GuidedActionsStylist();
     }
 
@@ -302,8 +300,7 @@
      * The default implementation returns a basic GuidedActionsStylist.
      * @return The GuidedActionsStylist used in this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist onCreateButtonActionsStylist() {
+    public @NonNull GuidedActionsStylist onCreateButtonActionsStylist() {
         GuidedActionsStylist stylist = new GuidedActionsStylist();
         stylist.setAsButtonActions();
         return stylist;
@@ -695,8 +692,7 @@
      * Returns the current GuidedStepFragment on the fragment transaction stack.
      * @return The current GuidedStepFragment, if any, on the fragment transaction stack.
      */
-    @Nullable
-    public static GuidedStepFragment getCurrentGuidedStepFragment(
+    public static @Nullable GuidedStepFragment getCurrentGuidedStepFragment(
             @NonNull FragmentManager fm
     ) {
         Fragment f = fm.findFragmentByTag(TAG_LEAN_BACK_ACTIONS_FRAGMENT);
@@ -710,8 +706,7 @@
      * Returns the GuidanceStylist that displays guidance information for the user.
      * @return The GuidanceStylist for this fragment.
      */
-    @NonNull
-    public GuidanceStylist getGuidanceStylist() {
+    public @NonNull GuidanceStylist getGuidanceStylist() {
         return mGuidanceStylist;
     }
 
@@ -719,8 +714,7 @@
      * Returns the GuidedActionsStylist that displays the actions the user may take.
      * @return The GuidedActionsStylist for this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist getGuidedActionsStylist() {
+    public @NonNull GuidedActionsStylist getGuidedActionsStylist() {
         return mActionsStylist;
     }
 
@@ -728,8 +722,7 @@
      * Returns the list of button GuidedActions that the user may take in this fragment.
      * @return The list of button GuidedActions for this fragment.
      */
-    @NonNull
-    public List<GuidedAction> getButtonActions() {
+    public @NonNull List<GuidedAction> getButtonActions() {
         return mButtonActions;
     }
 
@@ -738,8 +731,7 @@
      * @param id  Id of the button action to search.
      * @return  GuidedAction object or null if not found.
      */
-    @Nullable
-    public GuidedAction findButtonActionById(long id) {
+    public @Nullable GuidedAction findButtonActionById(long id) {
         int index = findButtonActionPositionById(id);
         return index >= 0 ? mButtonActions.get(index) : null;
     }
@@ -764,8 +756,7 @@
      * Returns the GuidedActionsStylist that displays the button actions the user may take.
      * @return The GuidedActionsStylist for this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist getGuidedButtonActionsStylist() {
+    public @NonNull GuidedActionsStylist getGuidedButtonActionsStylist() {
         return mButtonActionsStylist;
     }
 
@@ -797,8 +788,7 @@
      * @return The View corresponding to the button action at the indicated position, or null if
      * that action is not currently onscreen.
      */
-    @Nullable
-    public View getButtonActionItemView(int position) {
+    public @Nullable View getButtonActionItemView(int position) {
         final RecyclerView.ViewHolder holder = mButtonActionsStylist.getActionsGridView()
                     .findViewHolderForPosition(position);
         return holder == null ? null : holder.itemView;
@@ -824,8 +814,7 @@
      * Returns the list of GuidedActions that the user may take in this fragment.
      * @return The list of GuidedActions for this fragment.
      */
-    @NonNull
-    public List<GuidedAction> getActions() {
+    public @NonNull List<GuidedAction> getActions() {
         return mActions;
     }
 
@@ -834,8 +823,7 @@
      * @param id  Id of the action to search.
      * @return  GuidedAction object or null if not found.
      */
-    @Nullable
-    public GuidedAction findActionById(long id) {
+    public @Nullable GuidedAction findActionById(long id) {
         int index = findActionPositionById(id);
         return index >= 0 ? mActions.get(index) : null;
     }
@@ -898,8 +886,7 @@
      * @return The View corresponding to the action at the indicated position, or null if that
      * action is not currently onscreen.
      */
-    @Nullable
-    public View getActionItemView(int position) {
+    public @Nullable View getActionItemView(int position) {
         final RecyclerView.ViewHolder holder = mActionsStylist.getActionsGridView()
                     .findViewHolderForPosition(position);
         return holder == null ? null : holder.itemView;
@@ -1008,8 +995,7 @@
      * @param savedInstanceState
      * @return Created background view or null if no background.
      */
-    @Nullable
-    public View onCreateBackgroundView(
+    public @Nullable View onCreateBackgroundView(
             @NonNull LayoutInflater inflater,
             @Nullable ViewGroup container,
             @Nullable Bundle savedInstanceState
@@ -1111,8 +1097,8 @@
      * {@inheritDoc}
      */
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater,@Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         if (DEBUG) Log.v(TAG, "onCreateView");
 
         resolveTheme();
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepSupportFragment.java
index 17ef924..60bd6ba 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/GuidedStepSupportFragment.java
@@ -30,8 +30,6 @@
 import android.widget.FrameLayout;
 import android.widget.LinearLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.app.ActivityCompat;
 import androidx.fragment.app.Fragment;
@@ -51,6 +49,9 @@
 import androidx.leanback.widget.NonOverlappingLinearLayout;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -243,9 +244,8 @@
      */
     @RestrictTo(LIBRARY_GROUP_PREFIX)
     public static class DummyFragment extends Fragment {
-        @NonNull
         @Override
-        public View onCreateView(
+        public @NonNull View onCreateView(
                 @NonNull LayoutInflater inflater,
                 @Nullable ViewGroup container,
                 @Nullable Bundle savedInstanceState
@@ -277,8 +277,7 @@
      * a basic GuidanceStylist.
      * @return The GuidanceStylist used in this fragment.
      */
-    @NonNull
-    public GuidanceStylist onCreateGuidanceStylist() {
+    public @NonNull GuidanceStylist onCreateGuidanceStylist() {
         return new GuidanceStylist();
     }
 
@@ -287,8 +286,7 @@
      * returns a basic GuidedActionsStylist.
      * @return The GuidedActionsStylist used in this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist onCreateActionsStylist() {
+    public @NonNull GuidedActionsStylist onCreateActionsStylist() {
         return new GuidedActionsStylist();
     }
 
@@ -297,8 +295,7 @@
      * The default implementation returns a basic GuidedActionsStylist.
      * @return The GuidedActionsStylist used in this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist onCreateButtonActionsStylist() {
+    public @NonNull GuidedActionsStylist onCreateButtonActionsStylist() {
         GuidedActionsStylist stylist = new GuidedActionsStylist();
         stylist.setAsButtonActions();
         return stylist;
@@ -690,8 +687,7 @@
      * Returns the current GuidedStepSupportFragment on the fragment transaction stack.
      * @return The current GuidedStepSupportFragment, if any, on the fragment transaction stack.
      */
-    @Nullable
-    public static GuidedStepSupportFragment getCurrentGuidedStepSupportFragment(
+    public static @Nullable GuidedStepSupportFragment getCurrentGuidedStepSupportFragment(
             @NonNull FragmentManager fm
     ) {
         Fragment f = fm.findFragmentByTag(TAG_LEAN_BACK_ACTIONS_FRAGMENT);
@@ -705,8 +701,7 @@
      * Returns the GuidanceStylist that displays guidance information for the user.
      * @return The GuidanceStylist for this fragment.
      */
-    @NonNull
-    public GuidanceStylist getGuidanceStylist() {
+    public @NonNull GuidanceStylist getGuidanceStylist() {
         return mGuidanceStylist;
     }
 
@@ -714,8 +709,7 @@
      * Returns the GuidedActionsStylist that displays the actions the user may take.
      * @return The GuidedActionsStylist for this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist getGuidedActionsStylist() {
+    public @NonNull GuidedActionsStylist getGuidedActionsStylist() {
         return mActionsStylist;
     }
 
@@ -723,8 +717,7 @@
      * Returns the list of button GuidedActions that the user may take in this fragment.
      * @return The list of button GuidedActions for this fragment.
      */
-    @NonNull
-    public List<GuidedAction> getButtonActions() {
+    public @NonNull List<GuidedAction> getButtonActions() {
         return mButtonActions;
     }
 
@@ -733,8 +726,7 @@
      * @param id  Id of the button action to search.
      * @return  GuidedAction object or null if not found.
      */
-    @Nullable
-    public GuidedAction findButtonActionById(long id) {
+    public @Nullable GuidedAction findButtonActionById(long id) {
         int index = findButtonActionPositionById(id);
         return index >= 0 ? mButtonActions.get(index) : null;
     }
@@ -759,8 +751,7 @@
      * Returns the GuidedActionsStylist that displays the button actions the user may take.
      * @return The GuidedActionsStylist for this fragment.
      */
-    @NonNull
-    public GuidedActionsStylist getGuidedButtonActionsStylist() {
+    public @NonNull GuidedActionsStylist getGuidedButtonActionsStylist() {
         return mButtonActionsStylist;
     }
 
@@ -792,8 +783,7 @@
      * @return The View corresponding to the button action at the indicated position, or null if
      * that action is not currently onscreen.
      */
-    @Nullable
-    public View getButtonActionItemView(int position) {
+    public @Nullable View getButtonActionItemView(int position) {
         final RecyclerView.ViewHolder holder = mButtonActionsStylist.getActionsGridView()
                     .findViewHolderForPosition(position);
         return holder == null ? null : holder.itemView;
@@ -819,8 +809,7 @@
      * Returns the list of GuidedActions that the user may take in this fragment.
      * @return The list of GuidedActions for this fragment.
      */
-    @NonNull
-    public List<GuidedAction> getActions() {
+    public @NonNull List<GuidedAction> getActions() {
         return mActions;
     }
 
@@ -829,8 +818,7 @@
      * @param id  Id of the action to search.
      * @return  GuidedAction object or null if not found.
      */
-    @Nullable
-    public GuidedAction findActionById(long id) {
+    public @Nullable GuidedAction findActionById(long id) {
         int index = findActionPositionById(id);
         return index >= 0 ? mActions.get(index) : null;
     }
@@ -893,8 +881,7 @@
      * @return The View corresponding to the action at the indicated position, or null if that
      * action is not currently onscreen.
      */
-    @Nullable
-    public View getActionItemView(int position) {
+    public @Nullable View getActionItemView(int position) {
         final RecyclerView.ViewHolder holder = mActionsStylist.getActionsGridView()
                     .findViewHolderForPosition(position);
         return holder == null ? null : holder.itemView;
@@ -1003,8 +990,7 @@
      * @param savedInstanceState
      * @return Created background view or null if no background.
      */
-    @Nullable
-    public View onCreateBackgroundView(
+    public @Nullable View onCreateBackgroundView(
             @NonNull LayoutInflater inflater,
             @Nullable ViewGroup container,
             @Nullable Bundle savedInstanceState
@@ -1106,9 +1092,8 @@
      * {@inheritDoc}
      */
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         if (DEBUG) Log.v(TAG, "onCreateView");
 
         resolveTheme();
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/HeadersFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/HeadersFragment.java
index 27ff655..d183e7a 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/HeadersFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/HeadersFragment.java
@@ -28,8 +28,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.widget.ClassPresenterSelector;
 import androidx.leanback.widget.DividerPresenter;
@@ -44,6 +42,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An fragment containing a list of row headers. Implementation must support three types of rows:
  * <ul>
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/HeadersSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/HeadersSupportFragment.java
index f601936..2c2b142 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/HeadersSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/HeadersSupportFragment.java
@@ -25,8 +25,6 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.widget.ClassPresenterSelector;
 import androidx.leanback.widget.DividerPresenter;
@@ -41,6 +39,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An fragment containing a list of row headers. Implementation must support three types of rows:
  * <ul>
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/ListRowDataAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/app/ListRowDataAdapter.java
index d4e06ab..fd5152c 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/ListRowDataAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/ListRowDataAdapter.java
@@ -1,9 +1,10 @@
 package androidx.leanback.app;
 
-import androidx.annotation.Nullable;
 import androidx.leanback.widget.ObjectAdapter;
 import androidx.leanback.widget.Row;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Wrapper class for {@link ObjectAdapter} used by {@link BrowseFragment} to initialize
  * {@link RowsFragment}. We use invisible rows to represent
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingFragment.java
index a0ec019..28a8b02 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingFragment.java
@@ -25,6 +25,7 @@
 import android.animation.AnimatorSet;
 import android.animation.ObjectAnimator;
 import android.animation.TimeInterpolator;
+import android.app.Fragment;
 import android.content.Context;
 import android.graphics.Color;
 import android.os.Bundle;
@@ -46,12 +47,12 @@
 import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.PagingIndicator;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -309,8 +310,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         resolveTheme();
         LayoutInflater localInflater = getThemeInflater(inflater);
         final ViewGroup view = (ViewGroup) localInflater.inflate(R.layout.lb_onboarding_fragment,
@@ -514,8 +515,7 @@
      * Returns the start button text if it's set through
      * {@link #setStartButtonText(CharSequence)}}. If no string was set, null is returned.
      */
-    @Nullable
-    public final CharSequence getStartButtonText() {
+    public final @Nullable CharSequence getStartButtonText() {
         return mStartButtonText;
     }
 
@@ -593,8 +593,7 @@
      *
      * @return The {@link Animator} object which runs the logo animation.
      */
-    @Nullable
-    protected Animator onCreateLogoAnimation() {
+    protected @Nullable Animator onCreateLogoAnimation() {
         return null;
     }
 
@@ -641,8 +640,7 @@
      *
      * @return The {@link Animator} object which runs the page enter animation.
      */
-    @Nullable
-    protected Animator onCreateEnterAnimation() {
+    protected @Nullable Animator onCreateEnterAnimation() {
         return null;
     }
 
@@ -778,8 +776,7 @@
      * Provides the entry animation for description view. This allows users to override the
      * default fade and slide animation. Returning null will disable the animation.
      */
-    @NonNull
-    protected Animator onCreateDescriptionAnimator() {
+    protected @NonNull Animator onCreateDescriptionAnimator() {
         return AnimatorInflater.loadAnimator(FragmentUtil.getContext(OnboardingFragment.this),
                 R.animator.lb_onboarding_description_enter);
     }
@@ -788,8 +785,7 @@
      * Provides the entry animation for title view. This allows users to override the
      * default fade and slide animation. Returning null will disable the animation.
      */
-    @NonNull
-    protected Animator onCreateTitleAnimator() {
+    protected @NonNull Animator onCreateTitleAnimator() {
         return AnimatorInflater.loadAnimator(FragmentUtil.getContext(OnboardingFragment.this),
                 R.animator.lb_onboarding_title_enter);
     }
@@ -817,8 +813,7 @@
      *
      * @return The title of the page.
      */
-    @Nullable
-    protected abstract CharSequence getPageTitle(int pageIndex);
+    protected abstract @Nullable CharSequence getPageTitle(int pageIndex);
 
     /**
      * Returns the description of the given page.
@@ -827,8 +822,7 @@
      *
      * @return The description of the page.
      */
-    @Nullable
-    protected abstract CharSequence getPageDescription(int pageIndex);
+    protected abstract @Nullable CharSequence getPageDescription(int pageIndex);
 
     /**
      * Returns the index of the current page.
@@ -850,8 +844,7 @@
      *
      * @return The background view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateBackgroundView(
+    protected abstract @Nullable View onCreateBackgroundView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
@@ -869,8 +862,7 @@
      *
      * @return The content view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateContentView(
+    protected abstract @Nullable View onCreateContentView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
@@ -888,8 +880,7 @@
      *
      * @return The foreground view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateForegroundView(
+    protected abstract @Nullable View onCreateForegroundView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingSupportFragment.java
index 92be32e..168643f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/OnboardingSupportFragment.java
@@ -43,12 +43,13 @@
 import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.PagingIndicator;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -304,9 +305,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         resolveTheme();
         LayoutInflater localInflater = getThemeInflater(inflater);
         final ViewGroup view = (ViewGroup) localInflater.inflate(R.layout.lb_onboarding_fragment,
@@ -510,8 +510,7 @@
      * Returns the start button text if it's set through
      * {@link #setStartButtonText(CharSequence)}}. If no string was set, null is returned.
      */
-    @Nullable
-    public final CharSequence getStartButtonText() {
+    public final @Nullable CharSequence getStartButtonText() {
         return mStartButtonText;
     }
 
@@ -589,8 +588,7 @@
      *
      * @return The {@link Animator} object which runs the logo animation.
      */
-    @Nullable
-    protected Animator onCreateLogoAnimation() {
+    protected @Nullable Animator onCreateLogoAnimation() {
         return null;
     }
 
@@ -637,8 +635,7 @@
      *
      * @return The {@link Animator} object which runs the page enter animation.
      */
-    @Nullable
-    protected Animator onCreateEnterAnimation() {
+    protected @Nullable Animator onCreateEnterAnimation() {
         return null;
     }
 
@@ -774,8 +771,7 @@
      * Provides the entry animation for description view. This allows users to override the
      * default fade and slide animation. Returning null will disable the animation.
      */
-    @NonNull
-    protected Animator onCreateDescriptionAnimator() {
+    protected @NonNull Animator onCreateDescriptionAnimator() {
         return AnimatorInflater.loadAnimator(getContext(),
                 R.animator.lb_onboarding_description_enter);
     }
@@ -784,8 +780,7 @@
      * Provides the entry animation for title view. This allows users to override the
      * default fade and slide animation. Returning null will disable the animation.
      */
-    @NonNull
-    protected Animator onCreateTitleAnimator() {
+    protected @NonNull Animator onCreateTitleAnimator() {
         return AnimatorInflater.loadAnimator(getContext(),
                 R.animator.lb_onboarding_title_enter);
     }
@@ -813,8 +808,7 @@
      *
      * @return The title of the page.
      */
-    @Nullable
-    protected abstract CharSequence getPageTitle(int pageIndex);
+    protected abstract @Nullable CharSequence getPageTitle(int pageIndex);
 
     /**
      * Returns the description of the given page.
@@ -823,8 +817,7 @@
      *
      * @return The description of the page.
      */
-    @Nullable
-    protected abstract CharSequence getPageDescription(int pageIndex);
+    protected abstract @Nullable CharSequence getPageDescription(int pageIndex);
 
     /**
      * Returns the index of the current page.
@@ -846,8 +839,7 @@
      *
      * @return The background view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateBackgroundView(
+    protected abstract @Nullable View onCreateBackgroundView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
@@ -865,8 +857,7 @@
      *
      * @return The content view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateContentView(
+    protected abstract @Nullable View onCreateContentView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
@@ -884,8 +875,7 @@
      *
      * @return The foreground view for the onboarding screen, or {@code null}.
      */
-    @Nullable
-    protected abstract View onCreateForegroundView(
+    protected abstract @Nullable View onCreateForegroundView(
             @NonNull LayoutInflater inflater,
             @NonNull ViewGroup container
     );
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackFragment.java
index 952bd94..1f5cc11 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackFragment.java
@@ -21,6 +21,7 @@
 import android.animation.TimeInterpolator;
 import android.animation.ValueAnimator;
 import android.animation.ValueAnimator.AnimatorUpdateListener;
+import android.app.Fragment;
 import android.content.Context;
 import android.graphics.Color;
 import android.graphics.drawable.ColorDrawable;
@@ -37,10 +38,7 @@
 import android.view.ViewGroup;
 import android.view.animation.AccelerateInterpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
-import android.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.animation.LogAccelerateInterpolator;
 import androidx.leanback.animation.LogDecelerateInterpolator;
@@ -63,6 +61,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for displaying playback controls and related content.
  *
@@ -915,8 +916,8 @@
             };
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         mRootView = inflater.inflate(R.layout.lb_playback_fragment, container, false);
         mBackgroundView = mRootView.findViewById(R.id.playback_fragment_background);
         mRowsFragment = (RowsFragment) getChildFragmentManager().findFragmentById(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackSupportFragment.java
index 11aa41a..1e1ded2 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/PlaybackSupportFragment.java
@@ -34,8 +34,6 @@
 import android.view.ViewGroup;
 import android.view.animation.AccelerateInterpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.R;
@@ -60,6 +58,9 @@
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for displaying playback controls and related content.
  *
@@ -910,9 +911,8 @@
             };
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-                             @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         mRootView = inflater.inflate(R.layout.lb_playback_fragment, container, false);
         mBackgroundView = mRootView.findViewById(R.id.playback_fragment_background);
         mRowsSupportFragment = (RowsSupportFragment) getChildFragmentManager().findFragmentById(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/RowsFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/RowsFragment.java
index fa9f12c..165d4e4 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/RowsFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/RowsFragment.java
@@ -26,8 +26,6 @@
 import android.view.animation.DecelerateInterpolator;
 import android.view.animation.Interpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.widget.BaseOnItemViewClickedListener;
 import androidx.leanback.widget.BaseOnItemViewSelectedListener;
@@ -44,6 +42,9 @@
 import androidx.leanback.widget.ViewHolderTask;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/RowsSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/RowsSupportFragment.java
index 64fe558..3cb8054 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/RowsSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/RowsSupportFragment.java
@@ -23,8 +23,6 @@
 import android.view.animation.DecelerateInterpolator;
 import android.view.animation.Interpolator;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.widget.BaseOnItemViewClickedListener;
 import androidx.leanback.widget.BaseOnItemViewSelectedListener;
@@ -41,6 +39,9 @@
 import androidx.leanback.widget.ViewHolderTask;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/SearchFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/SearchFragment.java
index 181c6e7..482491e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/SearchFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/SearchFragment.java
@@ -19,6 +19,7 @@
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 
 import android.Manifest;
+import android.app.Fragment;
 import android.content.Intent;
 import android.graphics.drawable.Drawable;
 import android.os.Bundle;
@@ -31,9 +32,6 @@
 import android.view.ViewGroup;
 import android.view.inputmethod.CompletionInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.BrowseFrameLayout;
 import androidx.leanback.widget.ObjectAdapter;
@@ -48,6 +46,8 @@
 import androidx.leanback.widget.SpeechRecognitionCallback;
 import androidx.leanback.widget.VerticalGridView;
 
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -296,8 +296,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         View root = inflater.inflate(R.layout.lb_search_fragment, container, false);
 
         BrowseFrameLayout searchFrame = root.findViewById(R.id.lb_search_frame);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/SearchSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/SearchSupportFragment.java
index 17ffd88..08bd138 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/SearchSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/SearchSupportFragment.java
@@ -28,8 +28,6 @@
 import android.view.ViewGroup;
 import android.view.inputmethod.CompletionInfo;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.leanback.R;
 import androidx.leanback.widget.BrowseFrameLayout;
@@ -45,6 +43,9 @@
 import androidx.leanback.widget.SpeechRecognitionCallback;
 import androidx.leanback.widget.VerticalGridView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -291,9 +292,8 @@
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         View root = inflater.inflate(R.layout.lb_search_fragment, container, false);
 
         BrowseFrameLayout searchFrame = root.findViewById(R.id.lb_search_frame);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridFragment.java
index 85a8974..b5cb70c 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridFragment.java
@@ -22,8 +22,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.util.StateMachine.State;
@@ -37,6 +35,9 @@
 import androidx.leanback.widget.RowPresenter;
 import androidx.leanback.widget.VerticalGridPresenter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for creating leanback vertical grids.
  *
@@ -97,8 +98,7 @@
     /**
      * Returns the grid presenter.
      */
-    @Nullable
-    public VerticalGridPresenter getGridPresenter() {
+    public @Nullable VerticalGridPresenter getGridPresenter() {
         return mGridPresenter;
     }
 
@@ -113,8 +113,7 @@
     /**
      * Returns the object adapter.
      */
-    @Nullable
-    public ObjectAdapter getAdapter() {
+    public @Nullable ObjectAdapter getAdapter() {
         return mAdapter;
     }
 
@@ -187,14 +186,13 @@
     /**
      * Returns the item clicked listener.
      */
-    @Nullable
-    public OnItemViewClickedListener getOnItemViewClickedListener() {
+    public @Nullable OnItemViewClickedListener getOnItemViewClickedListener() {
         return mOnItemViewClickedListener;
     }
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         ViewGroup root = (ViewGroup) inflater.inflate(R.layout.lb_vertical_grid_fragment,
                 container, false);
         ViewGroup gridFrame = (ViewGroup) root.findViewById(R.id.grid_frame);
@@ -257,8 +255,7 @@
     }
 
     @Override
-    @NonNull
-    protected Object createEntranceTransition() {
+    protected @NonNull Object createEntranceTransition() {
         return TransitionHelper.loadTransition(FragmentUtil.getContext(VerticalGridFragment.this),
                 R.transition.lb_vertical_grid_entrance_transition);
     }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridSupportFragment.java
index 4189876..41ccc03 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/VerticalGridSupportFragment.java
@@ -19,8 +19,6 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.leanback.util.StateMachine.State;
@@ -34,6 +32,9 @@
 import androidx.leanback.widget.RowPresenter;
 import androidx.leanback.widget.VerticalGridPresenter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A fragment for creating leanback vertical grids.
  *
@@ -92,8 +93,7 @@
     /**
      * Returns the grid presenter.
      */
-    @Nullable
-    public VerticalGridPresenter getGridPresenter() {
+    public @Nullable VerticalGridPresenter getGridPresenter() {
         return mGridPresenter;
     }
 
@@ -108,8 +108,7 @@
     /**
      * Returns the object adapter.
      */
-    @Nullable
-    public ObjectAdapter getAdapter() {
+    public @Nullable ObjectAdapter getAdapter() {
         return mAdapter;
     }
 
@@ -182,15 +181,13 @@
     /**
      * Returns the item clicked listener.
      */
-    @Nullable
-    public OnItemViewClickedListener getOnItemViewClickedListener() {
+    public @Nullable OnItemViewClickedListener getOnItemViewClickedListener() {
         return mOnItemViewClickedListener;
     }
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         ViewGroup root = (ViewGroup) inflater.inflate(R.layout.lb_vertical_grid_fragment,
                 container, false);
         ViewGroup gridFrame = (ViewGroup) root.findViewById(R.id.grid_frame);
@@ -253,8 +250,7 @@
     }
 
     @Override
-    @NonNull
-    protected Object createEntranceTransition() {
+    protected @NonNull Object createEntranceTransition() {
         return TransitionHelper.loadTransition(getContext(),
                 R.transition.lb_vertical_grid_entrance_transition);
     }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/VideoFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/VideoFragment.java
index 125240d5..f370a17 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/VideoFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/VideoFragment.java
@@ -23,10 +23,10 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Subclass of {@link PlaybackFragment} that is responsible for providing a {@link SurfaceView}
  * and rendering video.
@@ -45,8 +45,8 @@
     int mState = SURFACE_NOT_CREATED;
 
     @Override
-    @Nullable
-    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+    public @Nullable View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+            Bundle savedInstanceState) {
         ViewGroup root = (ViewGroup) super.onCreateView(inflater, container, savedInstanceState);
         mVideoSurface = (SurfaceView) LayoutInflater.from(FragmentUtil.getContext(VideoFragment.this)).inflate(
                 R.layout.lb_video_surface, root, false);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/app/VideoSupportFragment.java b/leanback/leanback/src/main/java/androidx/leanback/app/VideoSupportFragment.java
index 2a79f91..8f340b0 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/app/VideoSupportFragment.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/app/VideoSupportFragment.java
@@ -20,10 +20,11 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Subclass of {@link PlaybackSupportFragment} that is responsible for providing a {@link SurfaceView}
  * and rendering video.
@@ -40,9 +41,8 @@
     int mState = SURFACE_NOT_CREATED;
 
     @Override
-    @Nullable
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-            @Nullable Bundle savedInstanceState) {
+    public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
+            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
         ViewGroup root = (ViewGroup) super.onCreateView(inflater, container, savedInstanceState);
         mVideoSurface = (SurfaceView) LayoutInflater.from(getContext()).inflate(
                 R.layout.lb_video_surface, root, false);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/graphics/BoundsRule.java b/leanback/leanback/src/main/java/androidx/leanback/graphics/BoundsRule.java
index 55ea9ebd..25b445c 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/graphics/BoundsRule.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/graphics/BoundsRule.java
@@ -17,8 +17,8 @@
 
 import android.graphics.Rect;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This class contains the rules for updating the bounds of a
@@ -40,8 +40,7 @@
          * @param fraction Percentage of parent.
          * @return Newly created ValueRule.
          */
-        @NonNull
-        public static ValueRule inheritFromParent(float fraction) {
+        public static @NonNull ValueRule inheritFromParent(float fraction) {
             return new ValueRule(0, fraction);
         }
 
@@ -51,8 +50,7 @@
          * @param absoluteValue Absolute value.
          * @return Newly created ValueRule.
          */
-        @NonNull
-        public static ValueRule absoluteValue(int absoluteValue) {
+        public static @NonNull ValueRule absoluteValue(int absoluteValue) {
             return new ValueRule(absoluteValue, 0);
         }
 
@@ -63,8 +61,7 @@
          * @param value    Offset
          * @return Newly created ValueRule.
          */
-        @NonNull
-        public static ValueRule inheritFromParentWithOffset(float fraction, int value) {
+        public static @NonNull ValueRule inheritFromParentWithOffset(float fraction, int value) {
             return new ValueRule(value, fraction);
         }
 
@@ -159,18 +156,14 @@
     }
 
     /** {@link ValueRule} for left attribute of {@link BoundsRule} */
-    @Nullable
-    public ValueRule left;
+    public @Nullable ValueRule left;
 
     /** {@link ValueRule} for top attribute of {@link BoundsRule} */
-    @Nullable
-    public ValueRule top;
+    public @Nullable ValueRule top;
 
     /** {@link ValueRule} for right attribute of {@link BoundsRule} */
-    @Nullable
-    public ValueRule right;
+    public @Nullable ValueRule right;
 
     /** {@link ValueRule} for bottom attribute of {@link BoundsRule} */
-    @Nullable
-    public ValueRule bottom;
+    public @Nullable ValueRule bottom;
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/graphics/CompositeDrawable.java b/leanback/leanback/src/main/java/androidx/leanback/graphics/CompositeDrawable.java
index 0b0b320..478097e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/graphics/CompositeDrawable.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/graphics/CompositeDrawable.java
@@ -23,11 +23,12 @@
 import android.graphics.drawable.Drawable;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.graphics.drawable.DrawableCompat;
 import androidx.leanback.graphics.BoundsRule.ValueRule;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
@@ -52,9 +53,8 @@
             }
         }
 
-        @NonNull
         @Override
-        public Drawable newDrawable() {
+        public @NonNull Drawable newDrawable() {
             return new CompositeDrawable(this);
         }
 
@@ -77,14 +77,12 @@
     }
 
     @Override
-    @NonNull
-    public ConstantState getConstantState() {
+    public @NonNull ConstantState getConstantState() {
         return mState;
     }
 
     @Override
-    @NonNull
-    public Drawable mutate() {
+    public @NonNull Drawable mutate() {
         if (!mMutated && super.mutate() == this) {
             mState = new CompositeState(mState, this, null);
             final ArrayList<ChildDrawable> children = mState.mChildren;
@@ -116,16 +114,14 @@
     /**
      * Returns the {@link Drawable} for the given index.
      */
-    @NonNull
-    public Drawable getDrawable(int index) {
+    public @NonNull Drawable getDrawable(int index) {
         return mState.mChildren.get(index).mDrawable;
     }
 
     /**
      * Returns the {@link ChildDrawable} at the given index.
      */
-    @NonNull
-    public ChildDrawable getChildAt(int index) {
+    public @NonNull ChildDrawable getChildAt(int index) {
         return mState.mChildren.get(index);
     }
 
@@ -288,16 +284,14 @@
         /**
          * Returns the instance of {@link BoundsRule}.
          */
-        @NonNull
-        public BoundsRule getBoundsRule() {
+        public @NonNull BoundsRule getBoundsRule() {
             return this.mBoundsRule;
         }
 
         /**
          * Returns the {@link Drawable}.
          */
-        @NonNull
-        public Drawable getDrawable() {
+        public @NonNull Drawable getDrawable() {
             return mDrawable;
         }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/graphics/FitWidthBitmapDrawable.java b/leanback/leanback/src/main/java/androidx/leanback/graphics/FitWidthBitmapDrawable.java
index 6208b78..798f9c2 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/graphics/FitWidthBitmapDrawable.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/graphics/FitWidthBitmapDrawable.java
@@ -26,9 +26,10 @@
 import android.util.IntProperty;
 import android.util.Property;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Subclass of {@link Drawable} that can be used to draw a bitmap into a region. Bitmap
  * will be scaled to fit the full width of the region and will be aligned to the top left corner.
@@ -57,9 +58,8 @@
             mOffset = other.mOffset;
         }
 
-        @NonNull
         @Override
-        public Drawable newDrawable() {
+        public @NonNull Drawable newDrawable() {
             return new FitWidthBitmapDrawable(this);
         }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/MediaControllerAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/media/MediaControllerAdapter.java
index c7a1f64..7cf323e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/MediaControllerAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/MediaControllerAdapter.java
@@ -34,9 +34,10 @@
 import android.support.v4.media.session.PlaybackStateCompat;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.widget.PlaybackControlsRow;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A helper class for implementing a adapter layer for {@link MediaControllerCompat}.
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerAdapter.java
index 8bef683..0ece6b8 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerAdapter.java
@@ -23,9 +23,10 @@
 import android.os.Handler;
 import android.view.SurfaceHolder;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.IOException;
 
 /**
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerGlue.java
index 87dc17f..2d3a0da 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/MediaPlayerGlue.java
@@ -26,7 +26,6 @@
 import android.view.SurfaceHolder;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.widget.Action;
 import androidx.leanback.widget.ArrayObjectAdapter;
@@ -36,6 +35,8 @@
 import androidx.leanback.widget.Row;
 import androidx.leanback.widget.RowPresenter;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.IOException;
 import java.util.List;
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBannerControlGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBannerControlGlue.java
index aaa0103..af175c7 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBannerControlGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBannerControlGlue.java
@@ -25,7 +25,6 @@
 import android.view.View;
 
 import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.widget.AbstractDetailsDescriptionPresenter;
 import androidx.leanback.widget.Action;
@@ -36,6 +35,8 @@
 import androidx.leanback.widget.PlaybackRowPresenter;
 import androidx.leanback.widget.RowPresenter;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
@@ -230,7 +231,7 @@
      * @param impl Implementation to underlying media player.
      */
     public PlaybackBannerControlGlue(@NonNull Context context,
-            @NonNull int[] seekSpeeds,
+            int @NonNull [] seekSpeeds,
             T impl) {
         this(context, seekSpeeds, seekSpeeds, impl);
     }
@@ -247,8 +248,8 @@
      */
     public PlaybackBannerControlGlue(
             @NonNull Context context,
-            @NonNull int[] fastForwardSpeeds,
-            @NonNull int[] rewindSpeeds,
+            int @NonNull [] fastForwardSpeeds,
+            int @NonNull [] rewindSpeeds,
             T impl
     ) {
         super(context, impl);
@@ -321,9 +322,8 @@
         }
     }
 
-    @NonNull
     @Override
-    protected PlaybackRowPresenter onCreateRowPresenter() {
+    protected @NonNull PlaybackRowPresenter onCreateRowPresenter() {
         final AbstractDetailsDescriptionPresenter detailsPresenter =
                 new AbstractDetailsDescriptionPresenter() {
                     @Override
@@ -339,14 +339,14 @@
                 new PlaybackControlsRowPresenter(detailsPresenter) {
             @Override
             protected void onBindRowViewHolder(
-                    @NonNull RowPresenter.ViewHolder vh,
+                    RowPresenter.@NonNull ViewHolder vh,
                     @NonNull Object item
             ) {
                 super.onBindRowViewHolder(vh, item);
                 vh.setOnKeyListener(PlaybackBannerControlGlue.this);
             }
             @Override
-            protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder vh) {
+            protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder vh) {
                 super.onUnbindRowViewHolder(vh);
                 vh.setOnKeyListener(null);
             }
@@ -586,16 +586,14 @@
     /**
      * Returns the fast forward speeds.
      */
-    @NonNull
-    public int[] getFastForwardSpeeds() {
+    public int @NonNull [] getFastForwardSpeeds() {
         return mFastForwardSpeeds;
     }
 
     /**
      * Returns the rewind speeds.
      */
-    @NonNull
-    public int[] getRewindSpeeds() {
+    public int @NonNull [] getRewindSpeeds() {
         return mRewindSpeeds;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBaseControlGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBaseControlGlue.java
index 8c6e741..b948bef 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBaseControlGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackBaseControlGlue.java
@@ -24,8 +24,6 @@
 import android.view.View;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.widget.Action;
 import androidx.leanback.widget.ArrayObjectAdapter;
 import androidx.leanback.widget.ControlButtonPresenterSelector;
@@ -35,6 +33,9 @@
 import androidx.leanback.widget.PlaybackTransportRowPresenter;
 import androidx.leanback.widget.Presenter;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -293,8 +294,7 @@
         }
     }
 
-    @NonNull
-    protected abstract PlaybackRowPresenter onCreateRowPresenter();
+    protected abstract @NonNull PlaybackRowPresenter onCreateRowPresenter();
 
     /**
      * Sets the controls to auto hide after a timeout when media is playing.
@@ -358,16 +358,14 @@
     /**
      * Returns the playback controls row managed by the glue layer.
      */
-    @Nullable
-    public PlaybackControlsRow getControlsRow() {
+    public @Nullable PlaybackControlsRow getControlsRow() {
         return mControlsRow;
     }
 
     /**
      * Returns the playback controls row Presenter managed by the glue layer.
      */
-    @Nullable
-    public PlaybackRowPresenter getPlaybackRowPresenter() {
+    public @Nullable PlaybackRowPresenter getPlaybackRowPresenter() {
         return mControlsRowPresenter;
     }
 
@@ -523,8 +521,7 @@
     /**
      * @return The drawable representing cover image.
      */
-    @Nullable
-    public Drawable getArt() {
+    public @Nullable Drawable getArt() {
         return mCover;
     }
 
@@ -546,8 +543,7 @@
     /**
      * Return The media subtitle.
      */
-    @Nullable
-    public CharSequence getSubtitle() {
+    public @Nullable CharSequence getSubtitle() {
         return mSubtitle;
     }
 
@@ -568,8 +564,7 @@
     /**
      * Returns the title of the media item.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         return mTitle;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackControlGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackControlGlue.java
index 3c788c0..4dc42d7 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackControlGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackControlGlue.java
@@ -24,7 +24,6 @@
 import android.view.KeyEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.widget.AbstractDetailsDescriptionPresenter;
 import androidx.leanback.widget.Action;
@@ -38,6 +37,8 @@
 import androidx.leanback.widget.RowPresenter;
 import androidx.leanback.widget.SparseArrayObjectAdapter;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.ref.WeakReference;
 import java.util.List;
 
@@ -297,14 +298,14 @@
             setPlaybackRowPresenter(new PlaybackControlsRowPresenter(detailsPresenter) {
                 @Override
                 protected void onBindRowViewHolder(
-                        @NonNull RowPresenter.ViewHolder vh,
+                        RowPresenter.@NonNull ViewHolder vh,
                         @NonNull Object item
                 ) {
                     super.onBindRowViewHolder(vh, item);
                     vh.setOnKeyListener(PlaybackControlGlue.this);
                 }
                 @Override
-                protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder vh) {
+                protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder vh) {
                     super.onUnbindRowViewHolder(vh);
                     vh.setOnKeyListener(null);
                 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackGlue.java
index cd9ef12..7c48c4f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackGlue.java
@@ -20,8 +20,9 @@
 import android.content.Context;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -86,8 +87,7 @@
     /**
      * Returns the context.
      */
-    @NonNull
-    public Context getContext() {
+    public @NonNull Context getContext() {
         return mContext;
     }
 
@@ -125,8 +125,7 @@
      * @return A snapshot of list of PlayerCallbacks set on the Glue.
      */
     @SuppressLint("NullableCollection")
-    @Nullable
-    protected List<PlayerCallback> getPlayerCallbacks() {
+    protected @Nullable List<PlayerCallback> getPlayerCallbacks() {
         if (mPlayerCallbacks == null) {
             return null;
         }
@@ -279,8 +278,7 @@
     /**
      * @return Associated {@link PlaybackGlueHost} or null if not attached to host.
      */
-    @Nullable
-    public PlaybackGlueHost getHost() {
+    public @Nullable PlaybackGlueHost getHost() {
         return mPlaybackGlueHost;
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackTransportControlGlue.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackTransportControlGlue.java
index 34f9320..4e73978 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackTransportControlGlue.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlaybackTransportControlGlue.java
@@ -23,7 +23,6 @@
 import android.view.KeyEvent;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.widget.AbstractDetailsDescriptionPresenter;
 import androidx.leanback.widget.Action;
 import androidx.leanback.widget.ArrayObjectAdapter;
@@ -35,6 +34,8 @@
 import androidx.leanback.widget.PlaybackTransportRowPresenter;
 import androidx.leanback.widget.RowPresenter;
 
+import org.jspecify.annotations.NonNull;
+
 import java.lang.ref.WeakReference;
 
 /**
@@ -154,14 +155,14 @@
         PlaybackTransportRowPresenter rowPresenter = new PlaybackTransportRowPresenter() {
             @Override
             protected void onBindRowViewHolder(
-                    @NonNull RowPresenter.ViewHolder vh,
+                    RowPresenter.@NonNull ViewHolder vh,
                     @NonNull Object item
             ) {
                 super.onBindRowViewHolder(vh, item);
                 vh.setOnKeyListener(PlaybackTransportControlGlue.this);
             }
             @Override
-            protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder vh) {
+            protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder vh) {
                 super.onUnbindRowViewHolder(vh);
                 vh.setOnKeyListener(null);
             }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/PlayerAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/media/PlayerAdapter.java
index 38f28b4..6e075d4 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/PlayerAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/PlayerAdapter.java
@@ -16,8 +16,8 @@
 
 package androidx.leanback.media;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Base class that wraps underlying media player. The class is used by PlaybackGlue, for example
@@ -129,8 +129,7 @@
      * Gets callback for event of PlayerAdapter.
      * @return Client for event of PlayerAdapter.
      */
-    @Nullable
-    public final Callback getCallback() {
+    public final @Nullable Callback getCallback() {
         return mCallback;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/media/SurfaceHolderGlueHost.java b/leanback/leanback/src/main/java/androidx/leanback/media/SurfaceHolderGlueHost.java
index e9a48a9..41e3b89 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/media/SurfaceHolderGlueHost.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/media/SurfaceHolderGlueHost.java
@@ -18,7 +18,7 @@
 
 import android.view.SurfaceHolder;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Optional interface to be implemented by any subclass of {@link PlaybackGlueHost} that contains
@@ -31,5 +31,5 @@
     /**
      * Sets the {@link SurfaceHolder.Callback} on the the host.
      */
-    void setSurfaceHolderCallback(@Nullable SurfaceHolder.Callback callback);
+    void setSurfaceHolderCallback(SurfaceHolder.@Nullable Callback callback);
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/transition/TransitionHelper.java b/leanback/leanback/src/main/java/androidx/leanback/transition/TransitionHelper.java
index 7e2f8e4c..eabe2ca 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/transition/TransitionHelper.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/transition/TransitionHelper.java
@@ -35,10 +35,11 @@
 import android.view.Window;
 import android.view.animation.AnimationUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Helper for view transitions.
  */
@@ -61,16 +62,13 @@
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getSharedElementEnterTransition(@NonNull Window window) {
+    public static @Nullable Object getSharedElementEnterTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getSharedElementEnterTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setSharedElementEnterTransition(
             @NonNull Window window,
             @Nullable Object transition
@@ -80,16 +78,13 @@
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getSharedElementReturnTransition(@NonNull Window window) {
+    public static @Nullable Object getSharedElementReturnTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getSharedElementReturnTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setSharedElementReturnTransition(
             @NonNull Window window,
             @Nullable Object transition
@@ -99,100 +94,79 @@
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getSharedElementExitTransition(@NonNull Window window) {
+    public static @Nullable Object getSharedElementExitTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getSharedElementExitTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getSharedElementReenterTransition(@NonNull Window window) {
+    public static @Nullable Object getSharedElementReenterTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getSharedElementReenterTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getEnterTransition(@NonNull Window window) {
+    public static @Nullable Object getEnterTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getEnterTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setEnterTransition(@NonNull Window window, @Nullable Object transition) {
         if (Build.VERSION.SDK_INT >= 21) {
             window.setEnterTransition((Transition) transition);
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getReturnTransition(@NonNull Window window) {
+    public static @Nullable Object getReturnTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getReturnTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setReturnTransition(@NonNull Window window, @Nullable Object transition) {
         if (Build.VERSION.SDK_INT >= 21) {
             window.setReturnTransition((Transition) transition);
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getExitTransition(@NonNull Window window) {
+    public static @Nullable Object getExitTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getExitTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object getReenterTransition(@NonNull Window window) {
+    public static @Nullable Object getReenterTransition(@NonNull Window window) {
         if (Build.VERSION.SDK_INT >= 21) {
             return window.getReenterTransition();
         }
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object createScene(@NonNull ViewGroup sceneRoot, @Nullable Runnable r) {
+    public static @Nullable Object createScene(@NonNull ViewGroup sceneRoot, @Nullable Runnable r) {
         Scene scene = new Scene(sceneRoot);
         scene.setEnterAction(r);
         return scene;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createChangeBounds(boolean reparent) {
+    public static @NonNull Object createChangeBounds(boolean reparent) {
         CustomChangeBounds changeBounds = new CustomChangeBounds();
         changeBounds.setReparent(reparent);
         return changeBounds;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createChangeTransform() {
+    public static @NonNull Object createChangeTransform() {
         if (Build.VERSION.SDK_INT >= 21) {
             return new ChangeTransform();
         }
         return new TransitionStub();
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setChangeBoundsStartDelay(
             @NonNull Object changeBounds,
             @NonNull View view,
@@ -224,42 +198,34 @@
         ((CustomChangeBounds) changeBounds).setDefaultStartDelay(startDelay);
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createTransitionSet(boolean sequential) {
+    public static @NonNull Object createTransitionSet(boolean sequential) {
         TransitionSet set = new TransitionSet();
         set.setOrdering(sequential ? TransitionSet.ORDERING_SEQUENTIAL
                 : TransitionSet.ORDERING_TOGETHER);
         return set;
     }
 
-    @NonNull
-    public static Object createSlide(int slideEdge) {
+    public static @NonNull Object createSlide(int slideEdge) {
         SlideKitkat slide = new SlideKitkat();
         slide.setSlideEdge(slideEdge);
         return slide;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createScale() {
+    public static @NonNull Object createScale() {
         if (Build.VERSION.SDK_INT >= 21) {
             return new ChangeTransform();
         }
         return new Scale();
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void addTransition(@NonNull Object transitionSet, @NonNull Object transition) {
         ((TransitionSet) transitionSet).addTransition((Transition) transition);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void exclude(@NonNull Object transition, int targetId, boolean exclude) {
         ((Transition) transition).excludeTarget(targetId, exclude);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void exclude(
             @NonNull Object transition,
             @NonNull View targetView,
@@ -268,12 +234,10 @@
         ((Transition) transition).excludeTarget(targetView, exclude);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void excludeChildren(@NonNull Object transition, int targetId, boolean exclude) {
         ((Transition) transition).excludeChildren(targetId, exclude);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void excludeChildren(
             @NonNull Object transition,
             @NonNull View targetView,
@@ -282,39 +246,30 @@
         ((Transition) transition).excludeChildren(targetView, exclude);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void include(@NonNull Object transition, int targetId) {
         ((Transition) transition).addTarget(targetId);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void include(@NonNull Object transition, @NonNull View targetView) {
         ((Transition) transition).addTarget(targetView);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setStartDelay(@NonNull Object transition, long startDelay) {
         ((Transition) transition).setStartDelay(startDelay);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setDuration(@NonNull Object transition, long duration) {
         ((Transition) transition).setDuration(duration);
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createAutoTransition() {
+    public static @NonNull Object createAutoTransition() {
         return new AutoTransition();
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createFadeTransition(int fadeMode) {
+    public static @NonNull Object createFadeTransition(int fadeMode) {
         return new Fade(fadeMode);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void addTransitionListener(
             @NonNull Object transition,
             final @Nullable TransitionListener listener
@@ -352,7 +307,6 @@
         t.addListener((Transition.TransitionListener) listener.mImpl);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void removeTransitionListener(
             @NonNull Object transition,
             @Nullable TransitionListener listener
@@ -365,12 +319,10 @@
         listener.mImpl = null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void runTransition(@Nullable Object scene, @Nullable Object transition) {
         TransitionManager.go((Scene) scene, (Transition) transition);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setInterpolator(
             @NonNull Object transition,
             @Nullable Object timeInterpolator
@@ -378,14 +330,11 @@
         ((Transition) transition).setInterpolator((TimeInterpolator) timeInterpolator);
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void addTarget(@NonNull Object transition, @NonNull View view) {
         ((Transition) transition).addTarget(view);
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @Nullable
-    public static Object createDefaultInterpolator(@NonNull Context context) {
+    public static @Nullable Object createDefaultInterpolator(@NonNull Context context) {
         if (Build.VERSION.SDK_INT >= 21) {
             return AnimationUtils.loadInterpolator(context,
                     android.R.interpolator.fast_out_linear_in);
@@ -393,13 +342,11 @@
         return null;
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object loadTransition(@NonNull Context context, int resId) {
+    public static @NonNull Object loadTransition(@NonNull Context context, int resId) {
         return TransitionInflater.from(context).inflateTransition(resId);
     }
 
-    @SuppressLint({"ReferencesDeprecated", "ClassVerificationFailure"})
+    @SuppressLint("ReferencesDeprecated")
     public static void setEnterTransition(
             @NonNull Fragment fragment,
             @Nullable Object transition
@@ -409,7 +356,7 @@
         }
     }
 
-    @SuppressLint({"ReferencesDeprecated", "ClassVerificationFailure"})
+    @SuppressLint("ReferencesDeprecated")
     public static void setExitTransition(
             @NonNull Fragment fragment,
             @Nullable Object transition
@@ -419,7 +366,7 @@
         }
     }
 
-    @SuppressLint({"ReferencesDeprecated", "ClassVerificationFailure"})
+    @SuppressLint("ReferencesDeprecated")
     public static void setSharedElementEnterTransition(
             @NonNull Fragment fragment,
             @Nullable Object transition
@@ -429,7 +376,7 @@
         }
     }
 
-    @SuppressLint({"ReferencesDeprecated", "ClassVerificationFailure"})
+    @SuppressLint("ReferencesDeprecated")
     public static void addSharedElement(
             @NonNull FragmentTransaction ft,
             @NonNull View view,
@@ -440,18 +387,14 @@
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createFadeAndShortSlide(int edge) {
+    public static @NonNull Object createFadeAndShortSlide(int edge) {
         if (Build.VERSION.SDK_INT >= 21) {
             return new FadeAndShortSlide(edge);
         }
         return new TransitionStub();
     }
 
-    @SuppressLint("ClassVerificationFailure")
-    @NonNull
-    public static Object createFadeAndShortSlide(int edge, float distance) {
+    public static @NonNull Object createFadeAndShortSlide(int edge, float distance) {
         if (Build.VERSION.SDK_INT >= 21) {
             FadeAndShortSlide slide = new FadeAndShortSlide(edge);
             slide.setDistance(distance);
@@ -460,7 +403,6 @@
         return new TransitionStub();
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void beginDelayedTransition(
             @NonNull ViewGroup sceneRoot,
             @Nullable Object transitionObject
@@ -471,17 +413,15 @@
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setTransitionGroup(@NonNull ViewGroup viewGroup, boolean transitionGroup) {
         if (Build.VERSION.SDK_INT >= 21) {
             viewGroup.setTransitionGroup(transitionGroup);
         }
     }
 
-    @SuppressLint("ClassVerificationFailure")
     public static void setEpicenterCallback(
             @NonNull Object transition,
-            @Nullable final TransitionEpicenterCallback callback
+            final @Nullable TransitionEpicenterCallback callback
     ) {
         if (Build.VERSION.SDK_INT >= 21) {
             if (callback == null) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/util/StateMachine.java b/leanback/leanback/src/main/java/androidx/leanback/util/StateMachine.java
index f84a1fb..6a53c40 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/util/StateMachine.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/util/StateMachine.java
@@ -17,9 +17,10 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 
 /**
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractDetailsDescriptionPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractDetailsDescriptionPresenter.java
index 3ddf939..ee14b3a 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractDetailsDescriptionPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractDetailsDescriptionPresenter.java
@@ -22,10 +22,11 @@
 import android.view.ViewTreeObserver;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An abstract {@link Presenter} for rendering a detailed description of an
  * item. Typically this Presenter will be used in a {@link FullWidthDetailsOverviewRowPresenter}
@@ -131,18 +132,15 @@
             }
         }
 
-        @NonNull
-        public TextView getTitle() {
+        public @NonNull TextView getTitle() {
             return mTitle;
         }
 
-        @NonNull
-        public TextView getSubtitle() {
+        public @NonNull TextView getSubtitle() {
             return mSubtitle;
         }
 
-        @NonNull
-        public TextView getBody() {
+        public @NonNull TextView getBody() {
             return mBody;
         }
 
@@ -155,8 +153,7 @@
     }
 
     @Override
-    @NonNull
-    public final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public final @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         View v = LayoutInflater.from(parent.getContext())
             .inflate(R.layout.lb_details_description, parent, false);
         return new ViewHolder(v);
@@ -164,7 +161,7 @@
 
     @Override
     public final void onBindViewHolder(
-            @NonNull Presenter.ViewHolder viewHolder,
+            Presenter.@NonNull ViewHolder viewHolder,
             @Nullable Object item
     ) {
         ViewHolder vh = (ViewHolder) viewHolder;
@@ -225,10 +222,10 @@
     protected abstract void onBindDescription(@NonNull ViewHolder vh, @NonNull Object item);
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {}
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {}
 
     @Override
-    public void onViewAttachedToWindow(@NonNull Presenter.ViewHolder holder) {
+    public void onViewAttachedToWindow(Presenter.@NonNull ViewHolder holder) {
         // In case predraw listener was removed in detach, make sure
         // we have the proper layout.
         ViewHolder vh = (ViewHolder) holder;
@@ -237,7 +234,7 @@
     }
 
     @Override
-    public void onViewDetachedFromWindow(@NonNull Presenter.ViewHolder holder) {
+    public void onViewDetachedFromWindow(Presenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         vh.removePreDrawListener();
         super.onViewDetachedFromWindow(holder);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaItemPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaItemPresenter.java
index bb123b4..9d87a7a 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaItemPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaItemPresenter.java
@@ -27,9 +27,10 @@
 import android.widget.TextView;
 import android.widget.ViewFlipper;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -451,7 +452,7 @@
     }
 
     @Override
-    protected void onBindRowViewHolder(@NonNull RowPresenter.ViewHolder vh, @NonNull Object item) {
+    protected void onBindRowViewHolder(RowPresenter.@NonNull ViewHolder vh, @NonNull Object item) {
         super.onBindRowViewHolder(vh, item);
 
         final ViewHolder mvh = (ViewHolder) vh;
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaListHeaderPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaListHeaderPresenter.java
index 5a0faba..82013a6 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaListHeaderPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/AbstractMediaListHeaderPresenter.java
@@ -21,9 +21,10 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Abstract presenter class for rendering the header for a list of media items in a playlist.
  * The presenter creates a {@link ViewHolder} for the TextView holding the header text.
@@ -105,7 +106,7 @@
     }
 
     @Override
-    protected void onBindRowViewHolder(@NonNull RowPresenter.ViewHolder vh, @NonNull Object item) {
+    protected void onBindRowViewHolder(RowPresenter.@NonNull ViewHolder vh, @NonNull Object item) {
         super.onBindRowViewHolder(vh, item);
         onBindMediaListHeaderViewHolder((ViewHolder) vh, item);
     }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/Action.java b/leanback/leanback/src/main/java/androidx/leanback/widget/Action.java
index 33f49e2..2e7ede9 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/Action.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/Action.java
@@ -16,8 +16,8 @@
 import android.graphics.drawable.Drawable;
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 
@@ -110,8 +110,7 @@
     /**
      * Returns the first line label for this Action.
      */
-    @Nullable
-    public final CharSequence getLabel1() {
+    public final @Nullable CharSequence getLabel1() {
         return mLabel1;
     }
 
@@ -125,8 +124,7 @@
     /**
      * Returns the second line label for this Action.
      */
-    @Nullable
-    public final CharSequence getLabel2() {
+    public final @Nullable CharSequence getLabel2() {
         return mLabel2;
     }
 
@@ -140,8 +138,7 @@
     /**
      * Returns the icon drawable for this Action.
      */
-    @Nullable
-    public final Drawable getIcon() {
+    public final @Nullable Drawable getIcon() {
         return mIcon;
     }
 
@@ -167,8 +164,7 @@
     }
 
     @Override
-    @NonNull
-    public String toString(){
+    public @NonNull String toString() {
         StringBuilder sb = new StringBuilder();
         if (!TextUtils.isEmpty(mLabel1)) {
             sb.append(mLabel1);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ActionPresenterSelector.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ActionPresenterSelector.java
index 30235c0..98fba54 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ActionPresenterSelector.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ActionPresenterSelector.java
@@ -20,10 +20,11 @@
 import android.view.ViewGroup;
 import android.widget.Button;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 class ActionPresenterSelector extends PresenterSelector {
 
     private final Presenter mOneLineActionPresenter = new OneLineActionPresenter();
@@ -61,7 +62,7 @@
     abstract static class ActionPresenter extends Presenter {
         @Override
         public void onBindViewHolder(
-                @NonNull Presenter.ViewHolder viewHolder,
+                Presenter.@NonNull ViewHolder viewHolder,
                 @Nullable Object item
         ) {
             Action action = (Action) item;
@@ -87,7 +88,7 @@
         }
 
         @Override
-        public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+        public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
             ActionViewHolder vh = (ActionViewHolder) viewHolder;
             vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
             vh.view.setPadding(0, 0, 0, 0);
@@ -96,9 +97,8 @@
     }
 
     static class OneLineActionPresenter extends ActionPresenter {
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View v = LayoutInflater.from(parent.getContext())
                 .inflate(R.layout.lb_action_1_line, parent, false);
             return new ActionViewHolder(v, parent.getLayoutDirection());
@@ -106,7 +106,7 @@
 
         @Override
         public void onBindViewHolder(
-                @NonNull Presenter.ViewHolder viewHolder,
+                Presenter.@NonNull ViewHolder viewHolder,
                 @Nullable Object item
         ) {
             super.onBindViewHolder(viewHolder, item);
@@ -117,9 +117,8 @@
     }
 
     static class TwoLineActionPresenter extends ActionPresenter {
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View v = LayoutInflater.from(parent.getContext())
                 .inflate(R.layout.lb_action_2_lines, parent, false);
             return new ActionViewHolder(v, parent.getLayoutDirection());
@@ -127,7 +126,7 @@
 
         @Override
         public void onBindViewHolder(
-                @NonNull Presenter.ViewHolder viewHolder,
+                Presenter.@NonNull ViewHolder viewHolder,
                 @Nullable Object item
         ) {
             super.onBindViewHolder(viewHolder, item);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ArrayObjectAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ArrayObjectAdapter.java
index 38447fe..2a84485 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ArrayObjectAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ArrayObjectAdapter.java
@@ -15,11 +15,12 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.ListUpdateCallback;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -68,9 +69,8 @@
         return mItems.size();
     }
 
-    @Nullable
     @Override
-    public Object get(int index) {
+    public @Nullable Object get(int index) {
         return mItems.get(index);
     }
 
@@ -215,9 +215,8 @@
     /**
      * Gets a read-only view of the list of object of this ArrayObjectAdapter.
      */
-    @NonNull
     @SuppressWarnings("unchecked")
-    public <E> List<E> unmodifiableList() {
+    public <E> @NonNull List<E> unmodifiableList() {
 
         // The mUnmodifiableItems will only be created once as long as the content of mItems has not
         // been changed.
@@ -280,9 +279,8 @@
                         itemList.get(newItemPosition));
             }
 
-            @Nullable
             @Override
-            public Object getChangePayload(int oldItemPosition, int newItemPosition) {
+            public @Nullable Object getChangePayload(int oldItemPosition, int newItemPosition) {
                 return callback.getChangePayload(mOldItems.get(oldItemPosition),
                         itemList.get(newItemPosition));
             }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/BrowseFrameLayout.java b/leanback/leanback/src/main/java/androidx/leanback/widget/BrowseFrameLayout.java
index 95a1f3f..6d25e00 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/BrowseFrameLayout.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/BrowseFrameLayout.java
@@ -20,8 +20,8 @@
 import android.view.View;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * A ViewGroup for managing focus behavior between overlapping views.
@@ -37,8 +37,7 @@
          * Returns the view where focus should be requested given the current focused view and
          * the direction of focus search.
          */
-        @Nullable
-        View onFocusSearch(@Nullable View focused, int direction);
+        @Nullable View onFocusSearch(@Nullable View focused, int direction);
     }
 
     /**
@@ -86,8 +85,7 @@
     /**
      * Returns the {@link OnFocusSearchListener}.
      */
-    @Nullable
-    public OnFocusSearchListener getOnFocusSearchListener() {
+    public @Nullable OnFocusSearchListener getOnFocusSearchListener() {
         return mListener;
     }
 
@@ -101,8 +99,7 @@
     /**
      * Returns the {@link OnChildFocusListener}.
      */
-    @Nullable
-    public OnChildFocusListener getOnChildFocusListener() {
+    public @Nullable OnChildFocusListener getOnChildFocusListener() {
         return mOnChildFocusListener;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ClassPresenterSelector.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ClassPresenterSelector.java
index 5402396..9d0dd39 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ClassPresenterSelector.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ClassPresenterSelector.java
@@ -13,7 +13,7 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -61,9 +61,8 @@
         return this;
     }
 
-    @Nullable
     @Override
-    public Presenter getPresenter(@Nullable Object item) {
+    public @Nullable Presenter getPresenter(@Nullable Object item) {
         if (item == null) {
             return null;
         }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ControlBarPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ControlBarPresenter.java
index 1090d9e..37f29a2 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ControlBarPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ControlBarPresenter.java
@@ -19,10 +19,11 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A presenter that assumes a LinearLayout container for a series
  * of control buttons backed by objects of type {@link Action}.
@@ -239,16 +240,15 @@
         vh.mControlsContainer.setBackgroundColor(color);
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
         View v = LayoutInflater.from(parent.getContext())
             .inflate(getLayoutResourceId(), parent, false);
         return new ViewHolder(v);
     }
 
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder holder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder holder, @Nullable Object item) {
         ViewHolder vh = (ViewHolder) holder;
         BoundData data = (BoundData) item;
         if (vh.mAdapter != data.adapter) {
@@ -263,7 +263,7 @@
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder holder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         if (vh.mAdapter != null) {
             vh.mAdapter.unregisterObserver(vh.mDataObserver);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ControlButtonPresenterSelector.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ControlButtonPresenterSelector.java
index 468b05b..7100070 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ControlButtonPresenterSelector.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ControlButtonPresenterSelector.java
@@ -21,10 +21,11 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Displays primary and secondary controls for a {@link PlaybackControlsRow}.
  *
@@ -55,9 +56,8 @@
     /**
      * Always returns the presenter for primary controls.
      */
-    @Nullable
     @Override
-    public Presenter getPresenter(@Nullable Object item) {
+    public @Nullable Presenter getPresenter(@Nullable Object item) {
         return mPrimaryPresenter;
     }
 
@@ -86,9 +86,8 @@
             mLayoutResourceId = layoutResourceId;
         }
 
-        @NonNull
         @Override
-        public ViewHolder onCreateViewHolder(ViewGroup parent) {
+        public @NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
             View v = LayoutInflater.from(parent.getContext())
                     .inflate(mLayoutResourceId, parent, false);
             return new ActionViewHolder(v);
@@ -96,7 +95,7 @@
 
         @Override
         public void onBindViewHolder(
-                @NonNull Presenter.ViewHolder viewHolder,
+                Presenter.@NonNull ViewHolder viewHolder,
                 @Nullable Object item
         ) {
             Action action = (Action) item;
@@ -119,7 +118,7 @@
         }
 
         @Override
-        public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+        public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
             ActionViewHolder vh = (ActionViewHolder) viewHolder;
             vh.mIcon.setImageDrawable(null);
             if (vh.mLabel != null) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/CursorObjectAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/CursorObjectAdapter.java
index 170de9d..ce8758d 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/CursorObjectAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/CursorObjectAdapter.java
@@ -16,9 +16,10 @@
 import android.database.Cursor;
 import android.util.LruCache;
 
-import androidx.annotation.Nullable;
 import androidx.leanback.database.CursorMapper;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * An {@link ObjectAdapter} implemented with a {@link Cursor}.
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewLogoPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewLogoPresenter.java
index 3006565..3aca12f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewLogoPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewLogoPresenter.java
@@ -5,10 +5,11 @@
 import android.view.ViewGroup;
 import android.widget.ImageView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Presenter that responsible to create a ImageView and bind to DetailsOverviewRow. The default
  * implementation uses {@link DetailsOverviewRow#getImageDrawable()} and binds to {@link ImageView}.
@@ -31,23 +32,19 @@
      */
     public static class ViewHolder extends Presenter.ViewHolder {
 
-        @Nullable
-        protected FullWidthDetailsOverviewRowPresenter mParentPresenter;
-        @Nullable
-        protected FullWidthDetailsOverviewRowPresenter.ViewHolder mParentViewHolder;
+        protected @Nullable FullWidthDetailsOverviewRowPresenter mParentPresenter;
+        protected FullWidthDetailsOverviewRowPresenter.@Nullable ViewHolder mParentViewHolder;
         private boolean mSizeFromDrawableIntrinsic;
 
         public ViewHolder(@NonNull View view) {
             super(view);
         }
 
-        @Nullable
-        public FullWidthDetailsOverviewRowPresenter getParentPresenter() {
+        public @Nullable FullWidthDetailsOverviewRowPresenter getParentPresenter() {
             return mParentPresenter;
         }
 
-        @Nullable
-        public FullWidthDetailsOverviewRowPresenter.ViewHolder getParentViewHolder() {
+        public FullWidthDetailsOverviewRowPresenter.@Nullable ViewHolder getParentViewHolder() {
             return mParentViewHolder;
         }
 
@@ -90,15 +87,13 @@
      * @param parent Parent view.
      * @return View created for the logo.
      */
-    @NonNull
-    public View onCreateView(@NonNull ViewGroup parent) {
+    public @NonNull View onCreateView(@NonNull ViewGroup parent) {
         return LayoutInflater.from(parent.getContext())
                 .inflate(R.layout.lb_fullwidth_details_overview_logo, parent, false);
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         View view = onCreateView(parent);
         ViewHolder vh = new ViewHolder(view);
         ViewGroup.LayoutParams lp = view.getLayoutParams();
@@ -115,7 +110,7 @@
      * @param parentPresenter
      */
     public void setContext(@NonNull ViewHolder viewHolder,
-            @Nullable FullWidthDetailsOverviewRowPresenter.ViewHolder parentViewHolder,
+            FullWidthDetailsOverviewRowPresenter.@Nullable ViewHolder parentViewHolder,
             @Nullable FullWidthDetailsOverviewRowPresenter parentPresenter) {
         viewHolder.mParentViewHolder = parentViewHolder;
         viewHolder.mParentPresenter = parentPresenter;
@@ -144,7 +139,7 @@
      * @param item DetailsOverviewRow object to bind.
      */
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder viewHolder, @Nullable Object item) {
         DetailsOverviewRow row = (DetailsOverviewRow) item;
         ImageView imageView = ((ImageView) viewHolder.view);
         imageView.setImageDrawable(row.getImageDrawable());
@@ -178,7 +173,7 @@
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
     }
 
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRow.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRow.java
index 68bcf18..85b553f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRow.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRow.java
@@ -18,8 +18,8 @@
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
@@ -185,8 +185,7 @@
     /**
      * Returns the main item for the details page.
      */
-    @NonNull
-    public final Object getItem() {
+    public final @NonNull Object getItem() {
         return mItem;
     }
 
@@ -232,8 +231,7 @@
      * @return The overview's image drawable, or null if no drawable has been
      *         assigned.
      */
-    @Nullable
-    public final Drawable getImageDrawable() {
+    public final @Nullable Drawable getImageDrawable() {
         return mImageDrawable;
     }
 
@@ -319,8 +317,7 @@
     /**
      * Returns the {@link ObjectAdapter} for actions.
      */
-    @NonNull
-    public final ObjectAdapter getActionsAdapter() {
+    public final @NonNull ObjectAdapter getActionsAdapter() {
         return mActionsAdapter;
     }
 
@@ -343,8 +340,7 @@
     /**
      * Returns the Action associated with the given keycode, or null if no associated action exists.
      */
-    @Nullable
-    public Action getActionForKeyCode(int keyCode) {
+    public @Nullable Action getActionForKeyCode(int keyCode) {
         ObjectAdapter adapter = getActionsAdapter();
         if (adapter != null) {
             for (int i = 0; i < adapter.size(); i++) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRowPresenter.java
index 60cdfa6..e8d3603 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsOverviewRowPresenter.java
@@ -29,11 +29,12 @@
 import android.widget.ImageView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Renders a {@link DetailsOverviewRow} to display an overview of an item.
  * Typically this row will be the first row in a fragment
@@ -552,7 +553,7 @@
 
     @Override
     protected void onBindRowViewHolder(
-            @NonNull RowPresenter.ViewHolder holder,
+            RowPresenter.@NonNull ViewHolder holder,
             @NonNull Object item
     ) {
         super.onBindRowViewHolder(holder, item);
@@ -567,7 +568,7 @@
     }
 
     @Override
-    protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder holder) {
+    protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         DetailsOverviewRow dor = (DetailsOverviewRow) vh.getRow();
         dor.removeListener(vh.mListener);
@@ -594,7 +595,7 @@
     }
 
     @Override
-    protected void onRowViewAttachedToWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewAttachedToWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewAttachedToWindow(vh);
         if (mDetailsPresenter != null) {
             mDetailsPresenter.onViewAttachedToWindow(
@@ -603,7 +604,7 @@
     }
 
     @Override
-    protected void onRowViewDetachedFromWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewDetachedFromWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewDetachedFromWindow(vh);
         if (mDetailsPresenter != null) {
             mDetailsPresenter.onViewDetachedFromWindow(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsParallaxDrawable.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsParallaxDrawable.java
index 02c2421..ab4880e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsParallaxDrawable.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DetailsParallaxDrawable.java
@@ -24,12 +24,13 @@
 import android.util.TypedValue;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.R;
 import androidx.leanback.graphics.CompositeDrawable;
 import androidx.leanback.graphics.FitWidthBitmapDrawable;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper class responsible for wiring in parallax effect in
  * {@link androidx.leanback.app.DetailsFragment}. The default effect will render
@@ -146,16 +147,14 @@
     /**
      * @return First child which is cover drawable appearing at top.
      */
-    @NonNull
-    public Drawable getCoverDrawable() {
+    public @NonNull Drawable getCoverDrawable() {
         return getChildAt(0).getDrawable();
     }
 
     /**
      * @return Second child which is ColorDrawable by default.
      */
-    @NonNull
-    public Drawable getBottomDrawable() {
+    public @NonNull Drawable getBottomDrawable() {
         return mBottomDrawable;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DiffCallback.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DiffCallback.java
index 2d36342..d648454 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DiffCallback.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DiffCallback.java
@@ -16,10 +16,11 @@
 
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.DiffUtil;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -63,8 +64,7 @@
      * @see DiffUtil.Callback#getChangePayload(int, int)
      */
     @SuppressWarnings("WeakerAccess")
-    @Nullable
-    public Object getChangePayload(@NonNull Value oldItem, @NonNull Value newItem) {
+    public @Nullable Object getChangePayload(@NonNull Value oldItem, @NonNull Value newItem) {
         return null;
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/DividerPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/DividerPresenter.java
index 6479739..dd405aa 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/DividerPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/DividerPresenter.java
@@ -19,11 +19,12 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * DividerPresenter provides a default presentation for {@link DividerRow} in HeadersFragment.
  */
@@ -42,9 +43,8 @@
         mLayoutResourceId = layoutResourceId;
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         View headerView = LayoutInflater.from(parent.getContext())
                 .inflate(mLayoutResourceId, parent, false);
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/FragmentAnimationProvider.java b/leanback/leanback/src/main/java/androidx/leanback/widget/FragmentAnimationProvider.java
index 8aebed2..08c51a0 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/FragmentAnimationProvider.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/FragmentAnimationProvider.java
@@ -15,7 +15,7 @@
 
 import android.animation.Animator;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.List;
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/FullWidthDetailsOverviewRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/FullWidthDetailsOverviewRowPresenter.java
index f24979f..b40d809 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/FullWidthDetailsOverviewRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/FullWidthDetailsOverviewRowPresenter.java
@@ -25,11 +25,12 @@
 import android.view.ViewGroup.MarginLayoutParams;
 import android.widget.FrameLayout;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Renders a {@link DetailsOverviewRow} to display an overview of an item. Typically this row will
  * be the first row in a fragment such as the
@@ -576,7 +577,7 @@
 
     @Override
     protected void onBindRowViewHolder(
-            @NonNull RowPresenter.ViewHolder holder,
+            RowPresenter.@NonNull ViewHolder holder,
             @NonNull Object item
     ) {
         super.onBindRowViewHolder(holder, item);
@@ -590,7 +591,7 @@
     }
 
     @Override
-    protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder holder) {
+    protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         vh.onUnbind();
         mDetailsPresenter.onUnbindViewHolder(vh.mDetailsDescriptionViewHolder);
@@ -614,7 +615,7 @@
     }
 
     @Override
-    protected void onRowViewAttachedToWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewAttachedToWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewAttachedToWindow(vh);
         ViewHolder viewHolder = (ViewHolder) vh;
         mDetailsPresenter.onViewAttachedToWindow(viewHolder.mDetailsDescriptionViewHolder);
@@ -622,7 +623,7 @@
     }
 
     @Override
-    protected void onRowViewDetachedFromWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewDetachedFromWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewDetachedFromWindow(vh);
         ViewHolder viewHolder = (ViewHolder) vh;
         mDetailsPresenter.onViewDetachedFromWindow(viewHolder.mDetailsDescriptionViewHolder);
@@ -779,7 +780,7 @@
     }
 
     @Override
-    public void setEntranceTransitionState(@NonNull RowPresenter.ViewHolder holder,
+    public void setEntranceTransitionState(RowPresenter.@NonNull ViewHolder holder,
             boolean afterEntrance) {
         super.setEntranceTransitionState(holder, afterEntrance);
         if (mParticipatingEntranceTransition) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidanceStylist.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidanceStylist.java
index ebe9ab3..d3430a3 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidanceStylist.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidanceStylist.java
@@ -22,10 +22,11 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -106,8 +107,7 @@
          * Returns the title specified when this Guidance was constructed.
          * @return The title for this Guidance.
          */
-        @Nullable
-        public String getTitle() {
+        public @Nullable String getTitle() {
             return mTitle;
         }
 
@@ -115,8 +115,7 @@
          * Returns the description specified when this Guidance was constructed.
          * @return The description for this Guidance.
          */
-        @Nullable
-        public String getDescription() {
+        public @Nullable String getDescription() {
             return mDescription;
         }
 
@@ -124,8 +123,7 @@
          * Returns the breadcrumb specified when this Guidance was constructed.
          * @return The breadcrumb for this Guidance.
          */
-        @Nullable
-        public String getBreadcrumb() {
+        public @Nullable String getBreadcrumb() {
             return mBreadcrumb;
         }
 
@@ -133,8 +131,7 @@
          * Returns the icon drawable specified when this Guidance was constructed.
          * @return The icon for this Guidance.
          */
-        @Nullable
-        public Drawable getIconDrawable() {
+        public @Nullable Drawable getIconDrawable() {
             return mIconDrawable;
         }
     }
@@ -157,8 +154,7 @@
      * @param guidance The guidance data for the view.
      * @return The view to be added to the caller's view hierarchy.
      */
-    @NonNull
-    public View onCreateView(
+    public @NonNull View onCreateView(
             final @NonNull LayoutInflater inflater,
             @Nullable ViewGroup container,
             @NonNull Guidance guidance
@@ -239,8 +235,7 @@
      * Returns the view displaying the title of the guidance.
      * @return The text view object for the title.
      */
-    @Nullable
-    public TextView getTitleView() {
+    public @Nullable TextView getTitleView() {
         return mTitleView;
     }
 
@@ -248,8 +243,7 @@
      * Returns the view displaying the description of the guidance.
      * @return The text view object for the description.
      */
-    @Nullable
-    public TextView getDescriptionView() {
+    public @Nullable TextView getDescriptionView() {
         return mDescriptionView;
     }
 
@@ -257,8 +251,7 @@
      * Returns the view displaying the breadcrumb of the guidance.
      * @return The text view object for the breadcrumb.
      */
-    @Nullable
-    public TextView getBreadcrumbView() {
+    public @Nullable TextView getBreadcrumbView() {
         return mBreadcrumbView;
     }
 
@@ -266,8 +259,7 @@
      * Returns the view displaying the icon of the guidance.
      * @return The image view object for the icon.
      */
-    @Nullable
-    public ImageView getIconView() {
+    public @Nullable ImageView getIconView() {
         return mIconView;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedAction.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedAction.java
index bb05177..7595eb9 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedAction.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedAction.java
@@ -21,12 +21,13 @@
 import android.text.InputType;
 
 import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.core.content.ContextCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 
 /**
@@ -154,8 +155,7 @@
          * Returns Context of this Builder.
          * @return Context of this Builder.
          */
-        @NonNull
-        public Context getContext() {
+        public @NonNull Context getContext() {
             return mContext;
         }
 
@@ -556,7 +556,7 @@
          * @param hints List of hints for autofill.
          * @return The same BuilderBase object.
          */
-        public B autofillHints(@Nullable String... hints) {
+        public B autofillHints(String @Nullable ... hints) {
             mAutofillHints = hints;
             return (B) this;
         }
@@ -587,8 +587,7 @@
          * Builds the GuidedAction corresponding to this Builder.
          * @return The GuidedAction as configured through this Builder.
          */
-        @NonNull
-        public GuidedAction build() {
+        public @NonNull GuidedAction build() {
             GuidedAction action = new GuidedAction();
             applyValues(action);
             return action;
@@ -632,8 +631,7 @@
      * Returns the title of this action.
      * @return The title set when this action was built.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         return getLabel1();
     }
 
@@ -650,8 +648,7 @@
      * {@link #getTitle()}.
      * @return Optional title text to edit instead of {@link #getTitle()}.
      */
-    @Nullable
-    public CharSequence getEditTitle() {
+    public @Nullable CharSequence getEditTitle() {
         return mEditTitle;
     }
 
@@ -668,8 +665,7 @@
      * {@link #getDescription()}.
      * @return Optional description text to edit instead of {@link #getDescription()}.
      */
-    @Nullable
-    public CharSequence getEditDescription() {
+    public @Nullable CharSequence getEditDescription() {
         return mEditDescription;
     }
 
@@ -695,8 +691,7 @@
      * Returns the description of this action.
      * @return The description of this action.
      */
-    @Nullable
-    public CharSequence getDescription() {
+    public @Nullable CharSequence getDescription() {
         return getLabel2();
     }
 
@@ -712,8 +707,7 @@
      * Returns the intent associated with this action.
      * @return The intent set when this action was built.
      */
-    @Nullable
-    public Intent getIntent() {
+    public @Nullable Intent getIntent() {
         return mIntent;
     }
 
@@ -899,8 +893,7 @@
      * @return List of sub actions or null if sub actions list is not enabled.
      */
     @SuppressLint("NullableCollection")
-    @Nullable
-    public List<GuidedAction> getSubActions() {
+    public @Nullable List<GuidedAction> getSubActions() {
         return mSubActions;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapter.java
index 01ed098..cca2a79 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapter.java
@@ -25,13 +25,14 @@
 import android.widget.TextView;
 import android.widget.TextView.OnEditorActionListener;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -212,9 +213,8 @@
                             mActions.get(newItemPosition));
                 }
 
-                @Nullable
                 @Override
-                public Object getChangePayload(int oldItemPosition, int newItemPosition) {
+                public @Nullable Object getChangePayload(int oldItemPosition, int newItemPosition) {
                     return mDiffCallback.getChangePayload(oldActions.get(oldItemPosition),
                             mActions.get(newItemPosition));
                 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapterGroup.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapterGroup.java
index 57dcece..238facd 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapterGroup.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAdapterGroup.java
@@ -22,11 +22,12 @@
 import android.view.inputmethod.InputMethodManager;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.widget.GuidedActionAdapter.EditListener;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 
 /**
@@ -57,8 +58,7 @@
         }
     }
 
-    @Nullable
-    public GuidedActionAdapter getNextAdapter(@NonNull GuidedActionAdapter adapter) {
+    public @Nullable GuidedActionAdapter getNextAdapter(@NonNull GuidedActionAdapter adapter) {
         for (int i = 0; i < mAdapters.size(); i++) {
             Pair<GuidedActionAdapter, GuidedActionAdapter> pair = mAdapters.get(i);
             if (pair.first == adapter) {
@@ -126,7 +126,7 @@
 
     public void openIme(
             @NonNull GuidedActionAdapter adapter,
-            @NonNull GuidedActionsStylist.ViewHolder avh
+            GuidedActionsStylist.@NonNull ViewHolder avh
     ) {
         adapter.getGuidedActionsStylist().setEditingMode(avh, true);
         View v = avh.getEditingView();
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAppCompatEditText.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAppCompatEditText.java
index 0fc46c2..bcb5853 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAppCompatEditText.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionAppCompatEditText.java
@@ -27,12 +27,13 @@
 import android.view.autofill.AutofillValue;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.appcompat.widget.AppCompatEditText;
 import androidx.core.widget.TextViewCompat;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A custom EditText that satisfies the IME key monitoring requirements of GuidedStepFragment.
  */
@@ -127,7 +128,7 @@
      */
     @Override
     public void setCustomSelectionActionModeCallback(
-            @Nullable ActionMode.Callback actionModeCallback) {
+            ActionMode.@Nullable Callback actionModeCallback) {
         super.setCustomSelectionActionModeCallback(TextViewCompat
                 .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
     }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionDiffCallback.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionDiffCallback.java
index 2c56a80..aefe2ac 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionDiffCallback.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionDiffCallback.java
@@ -17,7 +17,7 @@
 
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * DiffCallback used for GuidedActions, see {@link
@@ -31,8 +31,7 @@
      * Returns the singleton GuidedActionDiffCallback.
      * @return The singleton GuidedActionDiffCallback.
      */
-    @NonNull
-    public static GuidedActionDiffCallback getInstance() {
+    public static @NonNull GuidedActionDiffCallback getInstance() {
         return sInstance;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionEditText.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionEditText.java
index 49606dc..766b1f2 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionEditText.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionEditText.java
@@ -29,10 +29,11 @@
 import android.widget.EditText;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.core.widget.TextViewCompat;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A custom EditText that satisfies the IME key monitoring requirements of GuidedStepFragment.
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionsStylist.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionsStylist.java
index 469d0fd..7a88493 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionsStylist.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedActionsStylist.java
@@ -48,8 +48,6 @@
 import android.widget.TextView;
 
 import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.ContextCompat;
@@ -61,6 +59,9 @@
 import androidx.leanback.widget.picker.DatePicker;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Calendar;
 import java.util.Collections;
 import java.util.List;
@@ -240,16 +241,14 @@
          * Returns the content view within this view holder's view, where title and description are
          * shown.
          */
-        @Nullable
-        public View getContentView() {
+        public @Nullable View getContentView() {
             return mContentView;
         }
 
         /**
          * Returns the title view within this view holder's view.
          */
-        @Nullable
-        public TextView getTitleView() {
+        public @Nullable TextView getTitleView() {
             return mTitleView;
         }
 
@@ -257,16 +256,14 @@
          * Convenience method to return an editable version of the title, if possible,
          * or null if the title view isn't an EditText.
          */
-        @Nullable
-        public EditText getEditableTitleView() {
+        public @Nullable EditText getEditableTitleView() {
             return (mTitleView instanceof EditText) ? (EditText)mTitleView : null;
         }
 
         /**
          * Returns the description view within this view holder's view.
          */
-        @Nullable
-        public TextView getDescriptionView() {
+        public @Nullable TextView getDescriptionView() {
             return mDescriptionView;
         }
 
@@ -274,32 +271,28 @@
          * Convenience method to return an editable version of the description, if possible,
          * or null if the description view isn't an EditText.
          */
-        @Nullable
-        public EditText getEditableDescriptionView() {
+        public @Nullable EditText getEditableDescriptionView() {
             return (mDescriptionView instanceof EditText) ? (EditText)mDescriptionView : null;
         }
 
         /**
          * Returns the icon view within this view holder's view.
          */
-        @Nullable
-        public ImageView getIconView() {
+        public @Nullable ImageView getIconView() {
             return mIconView;
         }
 
         /**
          * Returns the checkmark view within this view holder's view.
          */
-        @Nullable
-        public ImageView getCheckmarkView() {
+        public @Nullable ImageView getCheckmarkView() {
             return mCheckmarkView;
         }
 
         /**
          * Returns the chevron view within this view holder's view.
          */
-        @Nullable
-        public ImageView getChevronView() {
+        public @Nullable ImageView getChevronView() {
             return mChevronView;
         }
 
@@ -344,8 +337,7 @@
          * @return Current editing title view or description view or activator view or null if not
          * in editing.
          */
-        @Nullable
-        public View getEditingView() {
+        public @Nullable View getEditingView() {
             switch(mEditingMode) {
             case EDITING_TITLE:
                 return mTitleView;
@@ -370,8 +362,7 @@
         /**
          * @return Currently bound action.
          */
-        @Nullable
-        public GuidedAction getAction() {
+        public @Nullable GuidedAction getAction() {
             return mAction;
         }
 
@@ -382,9 +373,8 @@
             }
         }
 
-        @Nullable
         @Override
-        public Object getFacet(@NonNull Class<?> facetClass) {
+        public @Nullable Object getFacet(@NonNull Class<?> facetClass) {
             if (facetClass == ItemAlignmentFacet.class) {
                 return sGuidedActionItemAlignFacet;
             }
@@ -457,9 +447,9 @@
      * <code>LayoutInflater.inflate</code>.
      * @return The view to be added to the caller's view hierarchy.
      */
-    @NonNull
     @SuppressWarnings("deprecation") /* defaultDisplay */
-    public View onCreateView(@NonNull LayoutInflater inflater, final @NonNull ViewGroup container) {
+    public @NonNull View onCreateView(@NonNull LayoutInflater inflater,
+            final @NonNull ViewGroup container) {
         TypedArray ta = inflater.getContext().getTheme().obtainStyledAttributes(
                 R.styleable.LeanbackGuidedStepTheme);
         float keylinePercent = ta.getFloat(R.styleable.LeanbackGuidedStepTheme_guidedStepKeyline,
@@ -569,8 +559,7 @@
      * Returns the VerticalGridView that displays the list of GuidedActions.
      * @return The VerticalGridView for this presenter.
      */
-    @Nullable
-    public VerticalGridView getActionsGridView() {
+    public @Nullable VerticalGridView getActionsGridView() {
         return mActionsGridView;
     }
 
@@ -578,8 +567,7 @@
      * Returns the VerticalGridView that displays the sub actions list of an expanded action.
      * @return The VerticalGridView that displays the sub actions list of an expanded action.
      */
-    @Nullable
-    public VerticalGridView getSubActionsGridView() {
+    public @Nullable VerticalGridView getSubActionsGridView() {
         return mSubActionsGridView;
     }
 
@@ -669,8 +657,7 @@
      * @param parent The view group to be used as the parent of the new view.
      * @return The view to be added to the caller's view hierarchy.
      */
-    @NonNull
-    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         LayoutInflater inflater = LayoutInflater.from(parent.getContext());
         View v = inflater.inflate(onProvideItemLayoutId(), parent, false);
         return new ViewHolder(v, parent == mSubActionsGridView);
@@ -686,8 +673,7 @@
      * @param viewType The viewType returned by {@link #getItemViewType(GuidedAction)}
      * @return The view to be added to the caller's view hierarchy.
      */
-    @NonNull
-    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+    public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
         if (viewType == VIEW_TYPE_DEFAULT) {
             return onCreateViewHolder(parent);
         }
@@ -797,7 +783,7 @@
 
         getActionsGridView().setSelectedPosition(actionIndex, new ViewHolderTask() {
             @Override
-            public void run(@NonNull RecyclerView.ViewHolder viewHolder) {
+            public void run(RecyclerView.@NonNull ViewHolder viewHolder) {
                 ViewHolder vh = (ViewHolder) viewHolder;
                 guidedActionAdapter.mGroup.openIme(guidedActionAdapter, vh);
             }
@@ -1408,8 +1394,7 @@
     /**
      * @return Current expanded GuidedAction or null if not expanded.
      */
-    @Nullable
-    public GuidedAction getExpandedAction() {
+    public @Nullable GuidedAction getExpandedAction() {
         return mExpandedAction;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedDatePickerAction.java b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedDatePickerAction.java
index 39b0708..e4f20ee 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedDatePickerAction.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/GuidedDatePickerAction.java
@@ -16,8 +16,8 @@
 import android.content.Context;
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 import java.util.Calendar;
 import java.util.TimeZone;
@@ -123,8 +123,7 @@
          * Builds the GuidedDatePickerAction corresponding to this Builder.
          * @return The GuidedDatePickerAction as configured through this Builder.
          */
-        @NonNull
-        public GuidedDatePickerAction build() {
+        public @NonNull GuidedDatePickerAction build() {
             GuidedDatePickerAction action = new GuidedDatePickerAction();
             applyDatePickerValues(action);
             return action;
@@ -143,8 +142,7 @@
      * be used.
      * @return Format of showing Date, e.g. "YMD".  Returns null if using current locale's default.
      */
-    @Nullable
-    public String getDatePickerFormat() {
+    public @Nullable String getDatePickerFormat() {
         return mDatePickerFormat;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ImageCardView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ImageCardView.java
index af5b59a..dc07239 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ImageCardView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ImageCardView.java
@@ -29,11 +29,12 @@
 import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A subclass of {@link BaseCardView} with an {@link ImageView} as its main region. The
  * {@link ImageCardView} is highly customizable and can be used for various use-cases by adjusting
@@ -302,8 +303,7 @@
     /**
      * Returns the main image view.
      */
-    @Nullable
-    public final ImageView getMainImageView() {
+    public final @Nullable ImageView getMainImageView() {
         return mImageView;
     }
 
@@ -369,8 +369,7 @@
     /**
      * Returns the ImageView drawable.
      */
-    @Nullable
-    public Drawable getMainImage() {
+    public @Nullable Drawable getMainImage() {
         if (mImageView == null) {
             return null;
         }
@@ -381,8 +380,7 @@
     /**
      * Returns the info area background drawable.
      */
-    @Nullable
-    public Drawable getInfoAreaBackground() {
+    public @Nullable Drawable getInfoAreaBackground() {
         if (mInfoArea != null) {
             return mInfoArea.getBackground();
         }
@@ -420,8 +418,7 @@
     /**
      * Returns the title text.
      */
-    @Nullable
-    public CharSequence getTitleText() {
+    public @Nullable CharSequence getTitleText() {
         if (mTitleView == null) {
             return null;
         }
@@ -442,8 +439,7 @@
     /**
      * Returns the content text.
      */
-    @Nullable
-    public CharSequence getContentText() {
+    public @Nullable CharSequence getContentText() {
         if (mContentView == null) {
             return null;
         }
@@ -469,8 +465,7 @@
     /**
      * Returns the badge image drawable.
      */
-    @Nullable
-    public Drawable getBadgeImage() {
+    public @Nullable Drawable getBadgeImage() {
         if (mBadgeImage == null) {
             return null;
         }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/LeanbackAppCompatViewInflater.java b/leanback/leanback/src/main/java/androidx/leanback/widget/LeanbackAppCompatViewInflater.java
index 1993c8d..cf4fe64 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/LeanbackAppCompatViewInflater.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/LeanbackAppCompatViewInflater.java
@@ -20,16 +20,16 @@
 import android.util.AttributeSet;
 import android.view.View;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatViewInflater;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /** Inflater that converts leanback non-AppCpmpat views in layout to AppCompat versions. */
 public class LeanbackAppCompatViewInflater extends AppCompatViewInflater {
 
     @Override
-    @NonNull
-    protected View createView(@Nullable Context context, @Nullable String name,
+    protected @NonNull View createView(@Nullable Context context, @Nullable String name,
             @Nullable AttributeSet attrs) {
         switch (name) {
             case "androidx.leanback.widget.GuidedActionEditText":
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ListRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ListRowPresenter.java
index 1a3a86f..f38a411 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ListRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ListRowPresenter.java
@@ -20,13 +20,14 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.system.Settings;
 import androidx.leanback.transition.TransitionHelper;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.HashMap;
 
 /**
@@ -87,8 +88,7 @@
          * Gets ListRowPresenter that creates this ViewHolder.
          * @return ListRowPresenter that creates this ViewHolder.
          */
-        @NonNull
-        public final ListRowPresenter getListRowPresenter() {
+        public final @NonNull ListRowPresenter getListRowPresenter() {
             return mListRowPresenter;
         }
 
@@ -96,8 +96,7 @@
          * Gets HorizontalGridView that shows a list of items.
          * @return HorizontalGridView that shows a list of items.
          */
-        @NonNull
-        public final HorizontalGridView getGridView() {
+        public final @NonNull HorizontalGridView getGridView() {
             return mGridView;
         }
 
@@ -105,8 +104,7 @@
          * Gets ItemBridgeAdapter that creates the list of items.
          * @return ItemBridgeAdapter that creates the list of items.
          */
-        @NonNull
-        public final ItemBridgeAdapter getBridgeAdapter() {
+        public final @NonNull ItemBridgeAdapter getBridgeAdapter() {
             return mItemBridgeAdapter;
         }
 
@@ -124,8 +122,7 @@
          * @param position Position of the item in adapter.
          * @return ViewHolder bounds to the item.
          */
-        @Nullable
-        public Presenter.ViewHolder getItemViewHolder(int position) {
+        public Presenter.@Nullable ViewHolder getItemViewHolder(int position) {
             ItemBridgeAdapter.ViewHolder ibvh = (ItemBridgeAdapter.ViewHolder) mGridView
                     .findViewHolderForAdapterPosition(position);
             if (ibvh == null) {
@@ -134,15 +131,13 @@
             return ibvh.getViewHolder();
         }
 
-        @Nullable
         @Override
-        public Presenter.ViewHolder getSelectedItemViewHolder() {
+        public Presenter.@Nullable ViewHolder getSelectedItemViewHolder() {
             return getItemViewHolder(getSelectedPosition());
         }
 
-        @Nullable
         @Override
-        public Object getSelectedItem() {
+        public @Nullable Object getSelectedItem() {
             ItemBridgeAdapter.ViewHolder ibvh = (ItemBridgeAdapter.ViewHolder) mGridView
                     .findViewHolderForAdapterPosition(getSelectedPosition());
             if (ibvh == null) {
@@ -203,8 +198,7 @@
          * Returns optional task to run when the item is selected, null for no task.
          * @return Optional task to run when the item is selected, null for no task.
          */
-        @Nullable
-        public Presenter.ViewHolderTask getItemTask() {
+        public Presenter.@Nullable ViewHolderTask getItemTask() {
             return mItemTask;
         }
 
@@ -217,7 +211,7 @@
         }
 
         @Override
-        public void run(@Nullable Presenter.ViewHolder holder) {
+        public void run(Presenter.@Nullable ViewHolder holder) {
             if (holder instanceof ListRowPresenter.ViewHolder) {
                 HorizontalGridView gridView = ((ListRowPresenter.ViewHolder) holder).getGridView();
                 androidx.leanback.widget.ViewHolderTask task = null;
@@ -225,7 +219,7 @@
                     task = new androidx.leanback.widget.ViewHolderTask() {
                         final Presenter.ViewHolderTask itemTask = mItemTask;
                         @Override
-                        public void run(@NonNull RecyclerView.ViewHolder rvh) {
+                        public void run(RecyclerView.@NonNull ViewHolder rvh) {
                             ItemBridgeAdapter.ViewHolder ibvh = (ItemBridgeAdapter.ViewHolder) rvh;
                             itemTask.run(ibvh.getViewHolder());
                         }
@@ -673,7 +667,7 @@
 
     @Override
     protected void onBindRowViewHolder(
-            @NonNull RowPresenter.ViewHolder holder,
+            RowPresenter.@NonNull ViewHolder holder,
             @NonNull Object item
     ) {
         super.onBindRowViewHolder(holder, item);
@@ -685,7 +679,7 @@
     }
 
     @Override
-    protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder holder) {
+    protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         vh.mGridView.setAdapter(null);
         vh.mItemBridgeAdapter.clear();
@@ -864,14 +858,14 @@
     }
 
     @Override
-    public void freeze(@NonNull RowPresenter.ViewHolder holder, boolean freeze) {
+    public void freeze(RowPresenter.@NonNull ViewHolder holder, boolean freeze) {
         ViewHolder vh = (ViewHolder) holder;
         vh.mGridView.setScrollEnabled(!freeze);
         vh.mGridView.setAnimateChildLayout(!freeze);
     }
 
     @Override
-    public void setEntranceTransitionState(@NonNull RowPresenter.ViewHolder holder,
+    public void setEntranceTransitionState(RowPresenter.@NonNull ViewHolder holder,
             boolean afterEntrance) {
         super.setEntranceTransitionState(holder, afterEntrance);
         ((ViewHolder) holder).mGridView.setChildrenVisibility(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/MediaItemActionPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/MediaItemActionPresenter.java
index 4dfff3e..f87b884 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/MediaItemActionPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/MediaItemActionPresenter.java
@@ -19,10 +19,11 @@
 import android.view.ViewGroup;
 import android.widget.ImageView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * The presenter displaying a custom action in {@link AbstractMediaItemPresenter}.
  * This is the default presenter for actions in media rows if no action presenter is provided by the
@@ -48,9 +49,8 @@
         }
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
         Context context = parent.getContext();
         View actionView = LayoutInflater.from(context)
                 .inflate(R.layout.lb_row_media_item_action, parent, false);
@@ -58,13 +58,13 @@
     }
 
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder viewHolder, @Nullable Object item) {
         ViewHolder actionViewHolder = (ViewHolder) viewHolder;
         MultiActionsProvider.MultiAction action = (MultiActionsProvider.MultiAction) item;
         actionViewHolder.getIcon().setImageDrawable(action.getCurrentDrawable());
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/MediaRowFocusView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/MediaRowFocusView.java
index 3c1cccd..a471ffe 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/MediaRowFocusView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/MediaRowFocusView.java
@@ -20,10 +20,11 @@
 import android.util.AttributeSet;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Creates a view for a media item row in a playlist
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/NonOverlappingLinearLayoutWithForeground.java b/leanback/leanback/src/main/java/androidx/leanback/widget/NonOverlappingLinearLayoutWithForeground.java
index 764f54e..b7edce5 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/NonOverlappingLinearLayoutWithForeground.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/NonOverlappingLinearLayoutWithForeground.java
@@ -22,7 +22,7 @@
 import android.util.AttributeSet;
 import android.widget.LinearLayout;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Implements foreground drawable before M and falls back to M's foreground implementation.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ObjectAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ObjectAdapter.java
index 7009217..5c2546e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ObjectAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ObjectAdapter.java
@@ -18,10 +18,11 @@
 import android.annotation.SuppressLint;
 import android.database.Observable;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Base class adapter to be used in leanback activities.  Provides access to a data model and is
  * decoupled from the presentation of the items via {@link PresenterSelector}.
@@ -199,8 +200,7 @@
     /**
      * Returns the presenter selector for this ObjectAdapter.
      */
-    @NonNull
-    public final PresenterSelector getPresenterSelector() {
+    public final @NonNull PresenterSelector getPresenterSelector() {
         return mPresenterSelector;
     }
 
@@ -327,8 +327,7 @@
     /**
      * Returns the {@link Presenter} for the given item from the adapter.
      */
-    @Nullable
-    public final Presenter getPresenter(@NonNull Object item) {
+    public final @Nullable Presenter getPresenter(@NonNull Object item) {
         if (mPresenterSelector == null) {
             throw new IllegalStateException("Presenter selector must not be null");
         }
@@ -343,8 +342,7 @@
     /**
      * Returns the item for the given position.
      */
-    @Nullable
-    public abstract Object get(int position);
+    public abstract @Nullable Object get(int position);
 
     /**
      * Returns the id for the given position.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/OnActionClickedListener.java b/leanback/leanback/src/main/java/androidx/leanback/widget/OnActionClickedListener.java
index 919c545..3c52243 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/OnActionClickedListener.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/OnActionClickedListener.java
@@ -13,7 +13,7 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Interface for receiving notification when an {@link Action} is clicked.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PageRow.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PageRow.java
index 05129a5..7a5c5e6 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PageRow.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PageRow.java
@@ -13,7 +13,7 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Used to represent content spanning full page.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PagingIndicator.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PagingIndicator.java
index 270e9c2..54628ad 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PagingIndicator.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PagingIndicator.java
@@ -40,12 +40,13 @@
 import android.view.animation.DecelerateInterpolator;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A page indicator with dots.
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsPresenter.java
index 5d81a3c..4bd3650 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsPresenter.java
@@ -28,11 +28,12 @@
 import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.util.MathUtil;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A presenter for a control bar that supports "more actions",
  * and toggling the set of controls between primary and secondary
@@ -313,16 +314,15 @@
         vh.mTotalTime.setLayoutParams(lp);
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(ViewGroup parent) {
         View v = LayoutInflater.from(parent.getContext())
             .inflate(getLayoutResourceId(), parent, false);
         return new ViewHolder(v);
     }
 
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder holder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder holder, @Nullable Object item) {
         ViewHolder vh = (ViewHolder) holder;
         BoundData data = (BoundData) item;
 
@@ -338,7 +338,7 @@
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder holder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder holder) {
         super.onUnbindViewHolder(holder);
         ViewHolder vh = (ViewHolder) holder;
         if (vh.mMoreActionsAdapter != null) {
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowPresenter.java
index 1052f81..ee14611 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowPresenter.java
@@ -27,13 +27,14 @@
 import android.widget.LinearLayout;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 import androidx.leanback.widget.ControlBarPresenter.OnControlClickedListener;
 import androidx.leanback.widget.ControlBarPresenter.OnControlSelectedListener;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A PlaybackControlsRowPresenter renders a {@link PlaybackControlsRow} to display a
  * series of playback control buttons. Typically this row will be the first row in a fragment
@@ -237,8 +238,7 @@
     /**
      * Returns the listener for {@link Action} click events.
      */
-    @Nullable
-    public OnActionClickedListener getOnActionClickedListener() {
+    public @Nullable OnActionClickedListener getOnActionClickedListener() {
         return mOnActionClickedListener;
     }
 
@@ -315,7 +315,7 @@
     }
 
     @Override
-    public void onReappear(@NonNull RowPresenter.ViewHolder rowViewHolder) {
+    public void onReappear(RowPresenter.@NonNull ViewHolder rowViewHolder) {
         showPrimaryActions((ViewHolder) rowViewHolder);
     }
 
@@ -336,9 +336,8 @@
         return context.getResources().getColor(R.color.lb_playback_progress_color_no_theme);
     }
 
-    @NonNull
     @Override
-    protected RowPresenter.ViewHolder createRowViewHolder(@NonNull ViewGroup parent) {
+    protected RowPresenter.@NonNull ViewHolder createRowViewHolder(@NonNull ViewGroup parent) {
         View v = LayoutInflater.from(parent.getContext())
             .inflate(R.layout.lb_playback_controls_row, parent, false);
         ViewHolder vh = new ViewHolder(v, mDescriptionPresenter);
@@ -380,7 +379,7 @@
 
     @Override
     protected void onBindRowViewHolder(
-            @NonNull RowPresenter.ViewHolder holder,
+            RowPresenter.@NonNull ViewHolder holder,
             @NonNull Object item
     ) {
         super.onBindRowViewHolder(holder, item);
@@ -458,7 +457,7 @@
     }
 
     @Override
-    protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder holder) {
+    protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         PlaybackControlsRow row = (PlaybackControlsRow) vh.getRow();
 
@@ -473,7 +472,7 @@
     }
 
     @Override
-    protected void onRowViewSelected(@NonNull RowPresenter.ViewHolder vh, boolean selected) {
+    protected void onRowViewSelected(RowPresenter.@NonNull ViewHolder vh, boolean selected) {
         super.onRowViewSelected(vh, selected);
         if (selected) {
             ((ViewHolder) vh).dispatchItemSelection();
@@ -481,7 +480,7 @@
     }
 
     @Override
-    protected void onRowViewAttachedToWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewAttachedToWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewAttachedToWindow(vh);
         if (mDescriptionPresenter != null) {
             mDescriptionPresenter.onViewAttachedToWindow(
@@ -490,7 +489,7 @@
     }
 
     @Override
-    protected void onRowViewDetachedFromWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewDetachedFromWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewDetachedFromWindow(vh);
         if (mDescriptionPresenter != null) {
             mDescriptionPresenter.onViewDetachedFromWindow(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowView.java
index 2f26df9..e14ff0f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackControlsRowView.java
@@ -23,7 +23,7 @@
 import android.view.View;
 import android.widget.LinearLayout;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * A LinearLayout that preserves the focused child view.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackRowPresenter.java
index b68a69a..e766367 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackRowPresenter.java
@@ -2,7 +2,7 @@
 
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * Subclass of {@link RowPresenter} that can define the desired behavior when the view
@@ -24,6 +24,6 @@
     /**
      * Provides hook to update the UI when the view reappears.
      */
-    public void onReappear(@NonNull RowPresenter.ViewHolder rowViewHolder) {
+    public void onReappear(RowPresenter.@NonNull ViewHolder rowViewHolder) {
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackTransportRowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackTransportRowPresenter.java
index 6a3f78a..2d29295 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackTransportRowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PlaybackTransportRowPresenter.java
@@ -28,11 +28,12 @@
 import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 import androidx.leanback.widget.ControlBarPresenter.OnControlClickedListener;
 import androidx.leanback.widget.ControlBarPresenter.OnControlSelectedListener;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Arrays;
 
 /**
@@ -718,7 +719,7 @@
 
     @Override
     protected void onBindRowViewHolder(
-            @NonNull RowPresenter.ViewHolder holder,
+            RowPresenter.@NonNull ViewHolder holder,
             @NonNull Object item
     ) {
         super.onBindRowViewHolder(holder, item);
@@ -760,7 +761,7 @@
     }
 
     @Override
-    protected void onUnbindRowViewHolder(@NonNull RowPresenter.ViewHolder holder) {
+    protected void onUnbindRowViewHolder(RowPresenter.@NonNull ViewHolder holder) {
         ViewHolder vh = (ViewHolder) holder;
         PlaybackControlsRow row = (PlaybackControlsRow) vh.getRow();
 
@@ -821,7 +822,7 @@
     }
 
     @Override
-    protected void onRowViewAttachedToWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewAttachedToWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewAttachedToWindow(vh);
         if (mDescriptionPresenter != null) {
             mDescriptionPresenter.onViewAttachedToWindow(
@@ -830,7 +831,7 @@
     }
 
     @Override
-    protected void onRowViewDetachedFromWindow(@NonNull RowPresenter.ViewHolder vh) {
+    protected void onRowViewDetachedFromWindow(RowPresenter.@NonNull ViewHolder vh) {
         super.onRowViewDetachedFromWindow(vh);
         if (mDescriptionPresenter != null) {
             mDescriptionPresenter.onViewDetachedFromWindow(
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/Presenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/Presenter.java
index 5ec356b..163b1cc 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/Presenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/Presenter.java
@@ -16,10 +16,11 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.collection.ArrayMap;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 import java.util.Map;
 
@@ -122,8 +123,7 @@
     /**
      * Creates a new {@link View}.
      */
-    @NonNull
-    public abstract ViewHolder onCreateViewHolder(@NonNull ViewGroup parent);
+    public abstract @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent);
 
     /**
      * Binds a {@link View} to an item.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/PresenterSelector.java b/leanback/leanback/src/main/java/androidx/leanback/widget/PresenterSelector.java
index 3dae168..00f969e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/PresenterSelector.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/PresenterSelector.java
@@ -15,7 +15,7 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * A PresenterSelector is used to obtain a {@link Presenter} for a given Object.
@@ -25,16 +25,14 @@
     /**
      * Returns a presenter for the given item.
      */
-    @Nullable
-    public abstract Presenter getPresenter(@Nullable Object item);
+    public abstract @Nullable Presenter getPresenter(@Nullable Object item);
 
     /**
      * Returns an array of all possible presenters.  The returned array should
      * not be modified.
      */
     @SuppressLint("NullableCollection")
-    @Nullable
-    public Presenter[] getPresenters() {
+    public Presenter @Nullable [] getPresenters() {
         return null;
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/RowContainerView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/RowContainerView.java
index 976a909..6ba742f 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/RowContainerView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/RowContainerView.java
@@ -24,9 +24,10 @@
 import android.widget.LinearLayout;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * RowContainerView wraps header and user defined row view
  */
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/RowHeaderPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/RowHeaderPresenter.java
index 386c59a..e994f56 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/RowHeaderPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/RowHeaderPresenter.java
@@ -22,11 +22,12 @@
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * RowHeaderPresenter provides a default presentation for {@link HeaderItem} using a
  * {@link RowHeaderView} and optionally a TextView for description. If a subclass creates its own
@@ -123,9 +124,8 @@
         }
     }
 
-    @NonNull
     @Override
-    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public Presenter.@NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         View root = LayoutInflater.from(parent.getContext())
                 .inflate(mLayoutResourceId, parent, false);
 
@@ -137,7 +137,7 @@
     }
 
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder viewHolder, @Nullable Object item) {
         HeaderItem headerItem = item == null ? null : ((Row) item).getHeaderItem();
         RowHeaderPresenter.ViewHolder vh = (RowHeaderPresenter.ViewHolder)viewHolder;
         if (headerItem == null) {
@@ -170,7 +170,7 @@
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
         RowHeaderPresenter.ViewHolder vh = (ViewHolder)viewHolder;
         if (vh.mTitleView != null) {
             vh.mTitleView.setText(null);
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/RowPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/RowPresenter.java
index c02f3d9..d1924cd 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/RowPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/RowPresenter.java
@@ -16,11 +16,12 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.app.HeadersFragment;
 import androidx.leanback.graphics.ColorOverlayDimmer;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * An abstract {@link Presenter} that renders an Object in RowsFragment, the object can be
  * subclass {@link Row} or a generic one.  When the object is not {@link Row} class,
@@ -297,8 +298,7 @@
          * Return {@link ViewHolder} of currently selected item inside a row ViewHolder.
          * @return The selected item's ViewHolder.
          */
-        @Nullable
-        public Presenter.ViewHolder getSelectedItemViewHolder() {
+        public Presenter.@Nullable ViewHolder getSelectedItemViewHolder() {
             return null;
         }
 
@@ -306,8 +306,7 @@
          * Return currently selected item inside a row ViewHolder.
          * @return The selected item.
          */
-        @Nullable
-        public Object getSelectedItem() {
+        public @Nullable Object getSelectedItem() {
             return null;
         }
     }
@@ -354,8 +353,7 @@
      * @param parent The parent View for the Row's view holder.
      * @return A ViewHolder for the Row's View.
      */
-    @NonNull
-    protected abstract ViewHolder createRowViewHolder(@NonNull ViewGroup parent);
+    protected abstract @NonNull ViewHolder createRowViewHolder(@NonNull ViewGroup parent);
 
     /**
      * Returns true if the Row view should clip its children.  The clipChildren
@@ -607,7 +605,7 @@
 
     @Override
     public final void onBindViewHolder(
-            @NonNull Presenter.ViewHolder viewHolder,
+            Presenter.@NonNull ViewHolder viewHolder,
             @Nullable Object item
     ) {
         onBindRowViewHolder(getRowViewHolder(viewHolder), item);
@@ -628,7 +626,7 @@
     }
 
     @Override
-    public final void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    public final void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
         onUnbindRowViewHolder(getRowViewHolder(viewHolder));
     }
 
@@ -646,7 +644,7 @@
     }
 
     @Override
-    public final void onViewAttachedToWindow(@NonNull Presenter.ViewHolder holder) {
+    public final void onViewAttachedToWindow(Presenter.@NonNull ViewHolder holder) {
         onRowViewAttachedToWindow(getRowViewHolder(holder));
     }
 
@@ -660,7 +658,7 @@
     }
 
     @Override
-    public final void onViewDetachedFromWindow(@NonNull Presenter.ViewHolder holder) {
+    public final void onViewDetachedFromWindow(Presenter.@NonNull ViewHolder holder) {
         onRowViewDetachedFromWindow(getRowViewHolder(holder));
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/SearchOrbView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/SearchOrbView.java
index 870bbf5..7691623 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/SearchOrbView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/SearchOrbView.java
@@ -33,11 +33,12 @@
 import android.widget.ImageView;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * <p>A widget that draws a search affordance, represented by a round background and an icon.</p>
  *
@@ -273,8 +274,7 @@
      *
      * @return the drawable used as the icon
      */
-    @Nullable
-    public Drawable getOrbIcon() {
+    public @Nullable Drawable getOrbIcon() {
         return mIconDrawable;
     }
 
@@ -335,8 +335,7 @@
     /**
      * Returns the {@link Colors} used to display the search orb.
      */
-    @Nullable
-    public Colors getOrbColors() {
+    public @Nullable Colors getOrbColors() {
         return mColors;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/SeekBar.java b/leanback/leanback/src/main/java/androidx/leanback/widget/SeekBar.java
index b1db6eb..043d3a7 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/SeekBar.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/SeekBar.java
@@ -28,11 +28,12 @@
 import android.util.AttributeSet;
 import android.view.View;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Replacement of SeekBar, has two bar heights and two thumb size when focused/not_focused.
  * The widget does not deal with KeyEvent, it's client's responsibility to set a key listener.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayContainer.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayContainer.java
index d46de1a..135ee63 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayContainer.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayContainer.java
@@ -24,9 +24,10 @@
 import android.widget.FrameLayout;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Provides an SDK version-independent wrapper to support shadows, color overlays, and rounded
  * corners.  It's not always preferred to create a ShadowOverlayContainer, use
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayHelper.java b/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayHelper.java
index 63a6da7..58f161e 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayHelper.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/ShadowOverlayHelper.java
@@ -23,7 +23,6 @@
 import androidx.leanback.R;
 import androidx.leanback.system.Settings;
 
-
 /**
  * ShadowOverlayHelper is a helper class for shadow, overlay color and rounded corner.
  * There are many choices to implement Shadow, overlay color.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/SinglePresenterSelector.java b/leanback/leanback/src/main/java/androidx/leanback/widget/SinglePresenterSelector.java
index 5c5f746..e1be2b3 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/SinglePresenterSelector.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/SinglePresenterSelector.java
@@ -13,8 +13,8 @@
  */
 package androidx.leanback.widget;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * A {@link PresenterSelector} that always returns the same {@link Presenter}.
@@ -31,15 +31,13 @@
         mPresenter = presenter;
     }
 
-    @Nullable
     @Override
-    public Presenter getPresenter(@Nullable Object item) {
+    public @Nullable Presenter getPresenter(@Nullable Object item) {
         return mPresenter;
     }
 
-    @NonNull
     @Override
-    public Presenter[] getPresenters() {
+    public Presenter @NonNull [] getPresenters() {
         return new Presenter[]{mPresenter};
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/SparseArrayObjectAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/SparseArrayObjectAdapter.java
index edc6233..48787a6 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/SparseArrayObjectAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/SparseArrayObjectAdapter.java
@@ -2,7 +2,7 @@
 
 import android.util.SparseArray;
 
-import androidx.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * An {@link ObjectAdapter} implemented with a {@link android.util.SparseArray}.
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/StreamingTextView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/StreamingTextView.java
index 3727721..4bd9ae3 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/StreamingTextView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/StreamingTextView.java
@@ -34,10 +34,11 @@
 import android.widget.EditText;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
 import androidx.core.widget.TextViewCompat;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 import java.util.Random;
 import java.util.regex.Matcher;
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/TitleView.java b/leanback/leanback/src/main/java/androidx/leanback/widget/TitleView.java
index f96033f..c22fe19 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/TitleView.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/TitleView.java
@@ -26,10 +26,11 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Title view for a leanback fragment.
  */
@@ -126,8 +127,7 @@
     /**
      * Returns the title text.
      */
-    @Nullable
-    public CharSequence getTitle() {
+    public @Nullable CharSequence getTitle() {
         return mTextView.getText();
     }
 
@@ -143,8 +143,7 @@
     /**
      * Returns the badge drawable.
      */
-    @Nullable
-    public Drawable getBadgeDrawable() {
+    public @Nullable Drawable getBadgeDrawable() {
         return mBadgeView.getDrawable();
     }
 
@@ -160,23 +159,21 @@
     /**
      *  Returns the view for the search affordance.
      */
-    @NonNull
-    public View getSearchAffordanceView() {
+    public @NonNull View getSearchAffordanceView() {
         return mSearchOrbView;
     }
 
     /**
      * Sets the {@link SearchOrbView.Colors} used to draw the search affordance.
      */
-    public void setSearchAffordanceColors(@NonNull SearchOrbView.Colors colors) {
+    public void setSearchAffordanceColors(SearchOrbView.@NonNull Colors colors) {
         mSearchOrbView.setOrbColors(colors);
     }
 
     /**
      * Returns the {@link SearchOrbView.Colors} used to draw the search affordance.
      */
-    @Nullable
-    public SearchOrbView.Colors getSearchAffordanceColors() {
+    public SearchOrbView.@Nullable Colors getSearchAffordanceColors() {
         return mSearchOrbView.getOrbColors();
     }
 
@@ -226,8 +223,7 @@
     }
 
     @Override
-    @NonNull
-    public TitleViewAdapter getTitleViewAdapter() {
+    public @NonNull TitleViewAdapter getTitleViewAdapter() {
         return mTitleViewAdapter;
     }
 }
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/TitleViewAdapter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/TitleViewAdapter.java
index f04143e..0b1a63b 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/TitleViewAdapter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/TitleViewAdapter.java
@@ -16,7 +16,7 @@
 import android.graphics.drawable.Drawable;
 import android.view.View;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * This class allows a customized widget class to implement {@link TitleViewAdapter.Provider}
@@ -106,7 +106,7 @@
      *
      * @param colors Colors used to draw search affordance.
      */
-    public void setSearchAffordanceColors(@NonNull SearchOrbView.Colors colors) {
+    public void setSearchAffordanceColors(SearchOrbView.@NonNull Colors colors) {
     }
 
     /**
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/VerticalGridPresenter.java b/leanback/leanback/src/main/java/androidx/leanback/widget/VerticalGridPresenter.java
index 3c3811b..188c4ef 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/VerticalGridPresenter.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/VerticalGridPresenter.java
@@ -19,12 +19,13 @@
 import android.view.View;
 import android.view.ViewGroup;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.leanback.R;
 import androidx.leanback.system.Settings;
 import androidx.leanback.transition.TransitionHelper;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * A presenter that renders objects in a {@link VerticalGridView}.
  */
@@ -88,8 +89,7 @@
             mGridView = view;
         }
 
-        @NonNull
-        public VerticalGridView getGridView() {
+        public @NonNull VerticalGridView getGridView() {
             return mGridView;
         }
     }
@@ -233,9 +233,8 @@
         return mUseFocusDimmer;
     }
 
-    @NonNull
     @Override
-    public final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+    public final @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
         ViewHolder vh = createGridViewHolder(parent);
         vh.mInitialized = false;
         vh.mItemBridgeAdapter = new VerticalGridItemBridgeAdapter();
@@ -249,8 +248,7 @@
     /**
      * Subclass may override this to inflate a different layout.
      */
-    @NonNull
-    protected ViewHolder createGridViewHolder(@NonNull ViewGroup parent) {
+    protected @NonNull ViewHolder createGridViewHolder(@NonNull ViewGroup parent) {
         View root = LayoutInflater.from(parent.getContext()).inflate(
                 R.layout.lb_vertical_grid, parent, false);
         return new ViewHolder((VerticalGridView) root.findViewById(R.id.browse_grid));
@@ -336,13 +334,12 @@
      *
      * @return   The options to be used for shadow, overlay and rounded corner.
      */
-    @NonNull
-    protected ShadowOverlayHelper.Options createShadowOverlayOptions() {
+    protected ShadowOverlayHelper.@NonNull Options createShadowOverlayOptions() {
         return ShadowOverlayHelper.Options.DEFAULT;
     }
 
     @Override
-    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, @Nullable Object item) {
+    public void onBindViewHolder(Presenter.@NonNull ViewHolder viewHolder, @Nullable Object item) {
         if (DEBUG) Log.v(TAG, "onBindViewHolder " + item);
         ViewHolder vh = (ViewHolder) viewHolder;
         vh.mItemBridgeAdapter.setAdapter((ObjectAdapter) item);
@@ -350,7 +347,7 @@
     }
 
     @Override
-    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    public void onUnbindViewHolder(Presenter.@NonNull ViewHolder viewHolder) {
         if (DEBUG) Log.v(TAG, "onUnbindViewHolder");
         ViewHolder vh = (ViewHolder) viewHolder;
         vh.mItemBridgeAdapter.setAdapter(null);
@@ -368,8 +365,7 @@
     /**
      * Returns the item selected listener.
      */
-    @Nullable
-    public final OnItemViewSelectedListener getOnItemViewSelectedListener() {
+    public final @Nullable OnItemViewSelectedListener getOnItemViewSelectedListener() {
         return mOnItemViewSelectedListener;
     }
 
@@ -386,8 +382,7 @@
     /**
      * Returns the item clicked listener.
      */
-    @Nullable
-    public final OnItemViewClickedListener getOnItemViewClickedListener() {
+    public final @Nullable OnItemViewClickedListener getOnItemViewClickedListener() {
         return mOnItemViewClickedListener;
     }
 
diff --git a/leanback/leanback/src/main/java/androidx/leanback/widget/picker/Picker.java b/leanback/leanback/src/main/java/androidx/leanback/widget/picker/Picker.java
index 8f6d9c2..82e59b1 100644
--- a/leanback/leanback/src/main/java/androidx/leanback/widget/picker/Picker.java
+++ b/leanback/leanback/src/main/java/androidx/leanback/widget/picker/Picker.java
@@ -31,14 +31,15 @@
 
 import androidx.annotation.IdRes;
 import androidx.annotation.LayoutRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.view.ViewCompat;
 import androidx.leanback.R;
 import androidx.leanback.widget.OnChildViewHolderSelectedListener;
 import androidx.leanback.widget.VerticalGridView;
 import androidx.recyclerview.widget.RecyclerView;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -122,8 +123,7 @@
      *
      * @return The list of separators populated between the picker column fields.
      */
-    @NonNull
-    public final List<CharSequence> getSeparators() {
+    public final @NonNull List<CharSequence> getSeparators() {
         return mSeparators;
     }
 
@@ -232,8 +232,7 @@
      * @param colIndex Index of PickerColumn.
      * @return PickerColumn at colIndex or null if {@link #setColumns(List)} is not called yet.
      */
-    @Nullable
-    public PickerColumn getColumnAt(int colIndex) {
+    public @Nullable PickerColumn getColumnAt(int colIndex) {
         if (mColumns == null) {
             return null;
         }
diff --git a/libraryversions.toml b/libraryversions.toml
index 7c87027..9b39bca 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -24,7 +24,7 @@
 COMPOSE_MATERIAL3 = "1.4.0-alpha05"
 COMPOSE_MATERIAL3_ADAPTIVE = "1.1.0-alpha08"
 COMPOSE_MATERIAL3_COMMON = "1.0.0-alpha01"
-# Adding a comment to prevent merge conflicts
+COMPOSE_MATERIAL3_XR = "1.0.0-alpha01"
 COMPOSE_RUNTIME = "1.8.0-beta01"
 CONSTRAINTLAYOUT = "2.2.0-beta01"
 CONSTRAINTLAYOUT_COMPOSE = "1.1.0-beta01"
@@ -180,6 +180,10 @@
 WINDOW_SIDECAR = "1.0.0-rc01"
 WORK = "2.10.0-rc01"
 XR = "1.0.0-alpha01"
+XR_ARCORE = "1.0.0-alpha01"
+XR_COMPOSE = "1.0.0-alpha01"
+XR_RUNTIME = "1.0.0-alpha01"
+XR_SCENECORE = "1.0.0-alpha01"
 
 [groups]
 ACTIVITY = { group = "androidx.activity", atomicGroupVersion = "versions.ACTIVITY" }
@@ -209,6 +213,7 @@
 COMPOSE_MATERIAL3 = { group = "androidx.compose.material3", atomicGroupVersion = "versions.COMPOSE_MATERIAL3" }
 COMPOSE_MATERIAL3_ADAPTIVE = { group = "androidx.compose.material3.adaptive", atomicGroupVersion = "versions.COMPOSE_MATERIAL3_ADAPTIVE" }
 COMPOSE_MATERIAL3_ADAPTIVE_NAVIGATION_SUITE = { group = "androidx.compose.material3", atomicGroupVersion = "versions.COMPOSE_MATERIAL3", overrideInclude = [ ":compose:material3:material3-adaptive-navigation-suite" ] }
+COMPOSE_MATERIAL3_XR = { group = "androidx.xr.compose.material3", atomicGroupVersion = "versions.COMPOSE_MATERIAL3_XR" }
 COMPOSE_RUNTIME = { group = "androidx.compose.runtime", atomicGroupVersion = "versions.COMPOSE" }
 COMPOSE_UI = { group = "androidx.compose.ui", atomicGroupVersion = "versions.COMPOSE" }
 CONCURRENT = { group = "androidx.concurrent", atomicGroupVersion = "versions.FUTURES" }
@@ -312,3 +317,7 @@
 WINDOW_SIDECAR = { group = "androidx.window.sidecar", atomicGroupVersion = "versions.WINDOW_SIDECAR" }
 WORK = { group = "androidx.work", atomicGroupVersion = "versions.WORK" }
 XR = { group = "androidx.xr", atomicGroupVersion = "versions.XR" }
+XR_ARCORE = { group = "androidx.xr.arcore", atomicGroupVersion = "versions.XR_ARCORE" }
+XR_COMPOSE = { group = "androidx.xr.compose", atomicGroupVersion = "versions.XR_COMPOSE" }
+XR_RUNTIME = { group = "androidx.xr.runtime", atomicGroupVersion = "versions.XR_RUNTIME" }
+XR_SCENECORE = { group = "androidx.xr.scenecore", atomicGroupVersion = "versions.XR_SCENECORE" }
diff --git a/lifecycle/lifecycle-runtime-compose/samples/build.gradle b/lifecycle/lifecycle-runtime-compose/samples/build.gradle
index 2468e8b..9e758aea 100644
--- a/lifecycle/lifecycle-runtime-compose/samples/build.gradle
+++ b/lifecycle/lifecycle-runtime-compose/samples/build.gradle
@@ -35,7 +35,7 @@
     compileOnly(project(":annotation:annotation-sampled"))
 
     implementation(libs.kotlinStdlib)
-    implementation project(":lifecycle:lifecycle-runtime-compose")
+    implementation(project(":lifecycle:lifecycle-runtime-compose"))
     implementation "androidx.compose.material:material:1.0.1"
 }
 
diff --git a/lifecycle/lifecycle-viewmodel-compose/samples/build.gradle b/lifecycle/lifecycle-viewmodel-compose/samples/build.gradle
index 683b8c6..21c677f 100644
--- a/lifecycle/lifecycle-viewmodel-compose/samples/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-compose/samples/build.gradle
@@ -36,9 +36,9 @@
 dependencies {
     compileOnly(project(":annotation:annotation-sampled"))
     implementation(libs.kotlinStdlib)
-    implementation project(":lifecycle:lifecycle-common-java8")
-    implementation project(":lifecycle:lifecycle-viewmodel-compose")
-    implementation project(":lifecycle:lifecycle-viewmodel-savedstate")
+    implementation(project(":lifecycle:lifecycle-common-java8"))
+    implementation(project(":lifecycle:lifecycle-viewmodel-compose"))
+    implementation(project(":lifecycle:lifecycle-viewmodel-savedstate"))
 }
 
 androidx {
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
index 2baa415..165f1a5 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
@@ -69,7 +69,7 @@
             dependencies {
                 implementation("androidx.activity:activity-compose:1.10.0-beta01")
                 implementation("androidx.compose.ui:ui:1.7.5")
-                implementation project(":navigation3:navigation3")
+                implementation(project(":navigation3:navigation3"))
             }
         }
 
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
index 5f097d1..47ad93c 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
@@ -55,7 +55,7 @@
 
         commonTest {
             dependencies {
-                implementation project(":lifecycle:lifecycle-runtime")
+                implementation(project(":lifecycle:lifecycle-runtime"))
                 implementation(libs.kotlinCoroutinesTest)
             }
         }
@@ -84,9 +84,9 @@
         androidInstrumentedTest {
             dependsOn(jvmTest)
             dependencies {
-                implementation project(":lifecycle:lifecycle-livedata-core")
+                implementation(project(":lifecycle:lifecycle-livedata-core"))
                 implementation ("androidx.fragment:fragment:1.3.0")
-                implementation project(":internal-testutils-runtime")
+                implementation(project(":internal-testutils-runtime"))
                 implementation(project(":lifecycle:lifecycle-viewmodel"))
                 implementation(libs.truth)
                 implementation(libs.testExtJunit)
diff --git a/lint-checks/integration-tests/lint-baseline.xml b/lint-checks/integration-tests/lint-baseline.xml
index 35eb074c..b1db38c 100644
--- a/lint-checks/integration-tests/lint-baseline.xml
+++ b/lint-checks/integration-tests/lint-baseline.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<issues format="6" by="lint 8.8.0-alpha06" type="baseline" client="gradle" dependencies="false" name="AGP (8.8.0-alpha06)" variant="all" version="8.8.0-alpha06">
+<issues format="6" by="lint 8.9.0-alpha01" type="baseline" client="gradle" dependencies="false" name="AGP (8.9.0-alpha01)" variant="all" version="8.9.0-alpha01">
 
     <issue
         id="MissingClass"
@@ -11,15 +11,6 @@
     </issue>
 
     <issue
-        id="NewApi"
-        message="Call requires API level 23 (current min is 21): `android.view.View#getAccessibilityClassName`"
-        errorLine1="        return view.getAccessibilityClassName();"
-        errorLine2="                    ~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/ClassVerificationFailureFromJava.java"/>
-    </issue>
-
-    <issue
         id="BanKeepAnnotation"
         message="Uses @Keep annotation"
         errorLine1="@Keep"
@@ -146,6 +137,60 @@
     </issue>
 
     <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="    static boolean recreate(@NonNull final Activity activity) {"
+        errorLine2="                            ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/sample/core/app/ActivityRecreator.java"/>
+    </issue>
+
+    <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="        LifecycleCheckCallbacks(@NonNull Activity aboutToRecreate) {"
+        errorLine2="                                ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/sample/core/app/ActivityRecreator.java"/>
+    </issue>
+
+    <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="    static boolean recreate(@NonNull final Activity activity) {"
+        errorLine2="                            ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/sample/core/app/ActivityRecreatorChecked.java"/>
+    </issue>
+
+    <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="        LifecycleCheckCallbacks(@NonNull Activity aboutToRecreate) {"
+        errorLine2="                                ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/sample/core/app/ActivityRecreatorChecked.java"/>
+    </issue>
+
+    <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="    protected ParcelableUsageJava(@NonNull Parcel in) {"
+        errorLine2="                                  ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/ParcelableUsageJava.java"/>
+    </issue>
+
+    <issue
+        id="JSpecifyNullness"
+        message="Switch nullness annotation to JSpecify"
+        errorLine1="    public void writeToParcel(@NonNull Parcel dest, int flags) {"
+        errorLine2="                              ~~~~~~~~">
+        <location
+            file="src/main/java/androidx/ParcelableUsageJava.java"/>
+    </issue>
+
+    <issue
         id="LongLogTag"
         message="The logging tag can be at most 23 characters, was 24 (ActivityRecreatorChecked)"
         errorLine1="                    Log.e(LOG_TAG, &quot;Exception while invoking performStopActivity&quot;, t);"
@@ -370,409 +415,4 @@
             file="src/main/java/androidx/RestrictToTestsAnnotationUsageKotlin.kt"/>
     </issue>
 
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="    @RequiresApi(21)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/sample/appcompat/widget/ActionBarBackgroundDrawable.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallToThis.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallToThis.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallToThis.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19"
-        errorLine1="    @RequiresApi(19)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 21) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeReferenceWithExistingClassJava.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="    @RequiresApi(21)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="    @RequiresApi(21)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="    @RequiresApi(21)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 17) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeStaticMethodReferenceJava.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 21) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeVoidMethodReferenceJava.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 17) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/ClassVerificationFailureFromJava.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 19) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/sample/core/widget/ListViewCompat.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 19) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/sample/core/widget/ListViewCompat.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        if (Build.VERSION.SDK_INT >= 19) {"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/sample/core/widget/ListViewCompatKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 21"
-        errorLine1="        return if (Build.VERSION.SDK_INT >= 19) {"
-        errorLine2="                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/sample/core/widget/ListViewCompatKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19"
-        errorLine1="@RequiresApi(19)"
-        errorLine2="~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiJava.java"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19"
-        errorLine1="@RequiresApi(19)"
-        errorLine2="~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19"
-        errorLine1="    @RequiresApi(19)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19"
-        errorLine1="@RequiresApi(19)"
-        errorLine2="~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 19 from outer annotation (`@RequiresApi(19)`)"
-        errorLine1="    @RequiresApi(16)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 16"
-        errorLine1="@RequiresApi(16)"
-        errorLine2="~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 16"
-        errorLine1="    @RequiresApi(16)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 16"
-        errorLine1="@RequiresApi(16)"
-        errorLine2="~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="ObsoleteSdkInt"
-        message="Unnecessary; `SDK_INT` is always >= 16"
-        errorLine1="    @RequiresApi(16)"
-        errorLine2="    ~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/RequiresApiKotlin.kt"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void callVarArgsMethodNoArgs(BaseAdapter adapter) {"
-        errorLine2="                                        ~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void callVarArgsMethodOneArg(BaseAdapter adapter, CharBuffer vararg) {"
-        errorLine2="                                        ~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void callVarArgsMethodOneArg(BaseAdapter adapter, CharBuffer vararg) {"
-        errorLine2="                                                             ~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void callVarArgsMethodManyArgs(BaseAdapter adapter, CharBuffer vararg1,"
-        errorLine2="                                          ~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void callVarArgsMethodManyArgs(BaseAdapter adapter, CharBuffer vararg1,"
-        errorLine2="                                                               ~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="            CharBuffer vararg2, CharBuffer vararg3) {"
-        errorLine2="            ~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="            CharBuffer vararg2, CharBuffer vararg3) {"
-        errorLine2="                                ~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void unsafeReferenceOnCastObject(Object secretDisplayCutout) {"
-        errorLine2="                                            ~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallOnCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void castReceiver(Notification.MessagingStyle style, Notification.Builder builder) {"
-        errorLine2="                             ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void castReceiver(Notification.MessagingStyle style, Notification.Builder builder) {"
-        errorLine2="                                                                ~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void castParameter(Notification.Builder builder, Notification.CarExtender extender) {"
-        errorLine2="                              ~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public void castParameter(Notification.Builder builder, Notification.CarExtender extender) {"
-        errorLine2="                                                            ~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public Drawable createAdaptiveIconDrawableReturnDrawable() {"
-        errorLine2="           ~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public AdaptiveIconDrawable createAndReturnAdaptiveIconDrawable() {"
-        errorLine2="           ~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public Object methodReturnsIconAsObject() {"
-        errorLine2="           ~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public Icon methodReturnsIconAsIcon() {"
-        errorLine2="           ~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public Notification.DecoratedCustomViewStyle callQualifiedConstructor() {"
-        errorLine2="           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeConstructorQualifiedClass.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="    public PrintAttributes.Builder unsafeReferenceWithQualifiedClasses("
-        errorLine2="           ~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="            PrintAttributes.Builder builder,"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java"/>
-    </issue>
-
-    <issue
-        id="UnknownNullness"
-        message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"
-        errorLine1="            PrintAttributes.MediaSize mediaSize"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java"/>
-    </issue>
-
 </issues>
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java
deleted file mode 100644
index 2128027..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixOnUnsafeCallWithImplicitVarArgsCast.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2023 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 androidx;
-
-import android.widget.BaseAdapter;
-
-import androidx.annotation.RequiresApi;
-
-import java.nio.CharBuffer;
-
-/**
- * Contains unsafe calls to a method with a variable number of arguments which are implicitly cast.
- */
-@SuppressWarnings("unused")
-public class AutofixOnUnsafeCallWithImplicitVarArgsCast {
-    /**
-     * Calls the vararg method with no args.
-     */
-    @RequiresApi(27)
-    public void callVarArgsMethodNoArgs(BaseAdapter adapter) {
-        adapter.setAutofillOptions();
-    }
-
-    /**
-     *Calls the vararg method with one args.
-     */
-    @RequiresApi(27)
-    public void callVarArgsMethodOneArg(BaseAdapter adapter, CharBuffer vararg) {
-        adapter.setAutofillOptions(vararg);
-    }
-
-    /**
-     * Calls the vararg method with multiple args.
-     */
-    @RequiresApi(27)
-    public void callVarArgsMethodManyArgs(BaseAdapter adapter, CharBuffer vararg1,
-            CharBuffer vararg2, CharBuffer vararg3) {
-        adapter.setAutofillOptions(vararg1, vararg2, vararg3);
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallOnCast.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallOnCast.java
deleted file mode 100644
index c3d7f33..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallOnCast.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.os.Build;
-import android.view.DisplayCutout;
-
-/**
- * Test class containing unsafe reference on cast object.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeCallOnCast {
-    /**
-     * Method making unsafe reference on cast object.
-     */
-    public void unsafeReferenceOnCastObject(Object secretDisplayCutout) {
-        if (Build.VERSION.SDK_INT >= 28) {
-            ((DisplayCutout) secretDisplayCutout).getSafeInsetTop();
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallToThis.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallToThis.java
deleted file mode 100644
index 6697751..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallToThis.java
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.os.Build;
-import android.view.ViewGroup;
-
-/**
- * Test class containing unsafe references to a parent's instance method.
- */
-@SuppressWarnings("unused")
-public abstract class AutofixUnsafeCallToThis extends ViewGroup {
-    /*
-     * Constructor to prevent complication error.
-     */
-    public AutofixUnsafeCallToThis() {
-        super(null);
-    }
-
-    /**
-     * Method making the unsafe reference on an implicit this.
-     */
-    public void unsafeReferenceOnImplicitThis() {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-            getClipToPadding();
-        }
-    }
-
-    /**
-     * Method making the unsafe reference on an explicit this.
-     */
-    public void unsafeReferenceOnExplicitThis() {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-            this.getClipToPadding();
-        }
-    }
-
-    /**
-     * Method making the unsafe reference on an explicit super.
-     */
-    public void unsafeReferenceOnExplicitSuper() {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-            super.getClipToPadding();
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java
deleted file mode 100644
index cc20718..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitParamCast.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2023 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 androidx;
-
-import android.app.Notification;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Tests to ensure the generated lint fix does not leave in implicit casts from a new argument type.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeCallWithImplicitParamCast {
-    /**
-     * This uses the Notification.MessagingStyle type, but setBuilder is defined on
-     * Notification.Style, and the two classes were introduced in different API levels.
-     */
-    @RequiresApi(24)
-    public void castReceiver(Notification.MessagingStyle style, Notification.Builder builder) {
-        style.setBuilder(builder);
-    }
-
-    /**
-     * This uses Notification.CarExtender, but extend is defined with Notification.Extender as a
-     * parameter, and the two classes were introduced at different API levels.
-     */
-    @RequiresApi(23)
-    public void castParameter(Notification.Builder builder, Notification.CarExtender extender) {
-        builder.extend(extender);
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java
deleted file mode 100644
index 0eed5cb..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeCallWithImplicitReturnCast.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.app.Notification;
-import android.graphics.drawable.AdaptiveIconDrawable;
-import android.graphics.drawable.Drawable;
-import android.graphics.drawable.Icon;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Tests to ensure the generated lint fix does not leave in implicit casts from a new return type.
- */
-@SuppressWarnings("unused")
-public abstract class AutofixUnsafeCallWithImplicitReturnCast {
-    /**
-     * This method creates an AdaptiveIconDrawable and implicitly casts it to Drawable.
-     */
-    @RequiresApi(26)
-    public Drawable createAdaptiveIconDrawableReturnDrawable() {
-        return new AdaptiveIconDrawable(null, null);
-    }
-
-    /**
-     * This method also creates an AdaptiveIconDrawable but does not cast it to Drawable.
-     */
-    @RequiresApi(26)
-    public AdaptiveIconDrawable createAndReturnAdaptiveIconDrawable() {
-        return new AdaptiveIconDrawable(null, null);
-    }
-
-    /**
-     * This calls a method returning an Icon and implicitly casts it to Object.
-     */
-    @RequiresApi(26)
-    public Object methodReturnsIconAsObject() {
-        return Icon.createWithAdaptiveBitmap(null);
-    }
-
-    /**
-     * This calls a method returning an Icon and returns it as an Icon.
-     */
-    @RequiresApi(26)
-    public Icon methodReturnsIconAsIcon() {
-        return Icon.createWithAdaptiveBitmap(null);
-    }
-
-    /**
-     * This uses the constructed value as Notification.Style in a method call.
-     */
-    @RequiresApi(24)
-    public void methodUsesStyleAsParam() {
-        useStyle(new Notification.DecoratedCustomViewStyle());
-    }
-
-    /**
-     * This is here so there's a method to use the DecoratedCustomViewStyle as a Style.
-     */
-    static boolean useStyle(Notification.Style style) {
-        return false;
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorQualifiedClass.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorQualifiedClass.java
deleted file mode 100644
index fbf96f9..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorQualifiedClass.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.app.Notification;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Class containing an unsafe constructor reference that uses a qualified type.
- */
-public class AutofixUnsafeConstructorQualifiedClass {
-    /**
-     * In the generated fix, the constructor call should not be `new DecoratedCustomViewStyle()`.
-     */
-    @RequiresApi(24)
-    public Notification.DecoratedCustomViewStyle callQualifiedConstructor() {
-        return new Notification.DecoratedCustomViewStyle();
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorReferenceJava.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorReferenceJava.java
deleted file mode 100644
index 6aac186..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeConstructorReferenceJava.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.content.Context;
-import android.os.Build;
-import android.view.View;
-import android.view.accessibility.AccessibilityNodeInfo;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeConstructorReferenceJava {
-
-    /**
-     * Unsafe reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    void unsafeReferenceWithSdkCheck(Context context) {
-        if (Build.VERSION.SDK_INT >= 30) {
-            AccessibilityNodeInfo node = new AccessibilityNodeInfo(new View(context), 1);
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeGenericMethodReferenceJava.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeGenericMethodReferenceJava.java
deleted file mode 100644
index 5628007..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeGenericMethodReferenceJava.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.content.Context;
-import android.os.Build;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeGenericMethodReferenceJava {
-
-    /**
-     * Unsafe reference to a generically-typed method with an SDK_INT check that satisfies the
-     * NewApi lint.
-     */
-    <T> T getSystemService(Context context, Class<T> serviceClass) {
-        if (Build.VERSION.SDK_INT >= 23) {
-            return context.getSystemService(serviceClass);
-        }
-        return null;
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java
deleted file mode 100644
index 134b28f..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeMethodWithQualifiedClass.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.print.PrintAttributes;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Test class containing unsafe method reference that uses qualified class names.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeMethodWithQualifiedClass {
-    /**
-     * This method call:
-     * - has a receiver of type PrintAttributes.Builder
-     * - takes param of type PrintAttributes.MediaSize
-     * - has return type PrintAttributes.Builder
-     * In the generated fix, all three types should appear as qualified class names.
-     */
-    @RequiresApi(19)
-    public PrintAttributes.Builder unsafeReferenceWithQualifiedClasses(
-            PrintAttributes.Builder builder,
-            PrintAttributes.MediaSize mediaSize
-    ) {
-        return builder.setMediaSize(mediaSize);
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingClassJava.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingClassJava.java
deleted file mode 100644
index eca9596..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingClassJava.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.content.res.ColorStateList;
-import android.os.Build;
-import android.view.View;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeReferenceWithExistingClassJava {
-
-    /**
-     * Unsafe reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    void unsafeReferenceWithSdkCheck(View view) {
-        if (Build.VERSION.SDK_INT >= 21) {
-            view.setBackgroundTintList(new ColorStateList(null, null));
-        }
-    }
-
-    @RequiresApi(23)
-    static class Api23Impl {
-        private Api23Impl() {
-            // This class is not instantiable.
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java
deleted file mode 100644
index efa821b..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeReferenceWithExistingFix.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2022 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 androidx;
-
-import android.content.res.ColorStateList;
-import android.graphics.drawable.Drawable;
-import android.view.View;
-
-import androidx.annotation.DoNotInline;
-import androidx.annotation.RequiresApi;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeReferenceWithExistingFix {
-
-    /**
-     * Unsafe reference to a new API with an already existing fix method in Api21Impl.
-     */
-    @RequiresApi(21)
-    void unsafeReferenceFixMethodExists(View view) {
-        view.setBackgroundTintList(new ColorStateList(null, null));
-    }
-
-    /**
-     * Unsafe reference to a new API without an existing fix method, but requiring API 21.
-     */
-    @RequiresApi(21)
-    void unsafeReferenceFixClassExists(Drawable drawable) {
-        drawable.getOutline(null);
-    }
-
-    @RequiresApi(21)
-    static class Api21Impl {
-        private Api21Impl() {
-            // This class is not instantiable.
-        }
-
-        @DoNotInline
-        static void setBackgroundTintList(View view, ColorStateList tint) {
-            view.setBackgroundTintList(tint);
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeStaticMethodReferenceJava.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeStaticMethodReferenceJava.java
deleted file mode 100644
index 4aec0ab..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeStaticMethodReferenceJava.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.os.Build;
-import android.view.View;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeStaticMethodReferenceJava {
-
-    /**
-     * Unsafe reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    int unsafeReferenceWithSdkCheck() {
-        if (Build.VERSION.SDK_INT >= 17) {
-            return View.generateViewId();
-        }
-        return -1;
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeVoidMethodReferenceJava.java b/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeVoidMethodReferenceJava.java
deleted file mode 100644
index ec43f28..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/AutofixUnsafeVoidMethodReferenceJava.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.content.res.ColorStateList;
-import android.os.Build;
-import android.view.View;
-
-/**
- * Test class containing unsafe method references.
- */
-@SuppressWarnings("unused")
-public class AutofixUnsafeVoidMethodReferenceJava {
-
-    /**
-     * Unsafe reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    void unsafeReferenceWithSdkCheck(View view) {
-        if (Build.VERSION.SDK_INT >= 21) {
-            view.setBackgroundTintList(new ColorStateList(null, null));
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/ClassVerificationFailureFromJava.java b/lint-checks/integration-tests/src/main/java/androidx/ClassVerificationFailureFromJava.java
deleted file mode 100644
index 9aaa657..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/ClassVerificationFailureFromJava.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import android.content.res.ColorStateList;
-import android.os.Build;
-import android.view.View;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Test class containing class verification failure scenarios.
- */
-@SuppressWarnings("unused")
-public class ClassVerificationFailureFromJava {
-
-    /**
-     * Unsafe reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    void unsafeReferenceWithSdkCheck(View view) {
-        if (Build.VERSION.SDK_INT > 23) {
-            ColorStateList tint = new ColorStateList(null, null);
-            view.setBackgroundTintList(tint);
-        }
-    }
-
-    /**
-     * Unsafe static reference to a new API with an SDK_INT check that satisfies the NewApi lint.
-     */
-    int unsafeStaticReferenceWithSdkCheck() {
-        if (Build.VERSION.SDK_INT >= 17) {
-            return View.generateViewId();
-        } else {
-            return -1;
-        }
-    }
-
-    /**
-     * Unsafe reference to a new API whose auto-fix collides with the existing Api28Impl class.
-     */
-    CharSequence unsafeReferenceWithAutoFixCollision(View view) {
-        return view.getAccessibilityClassName();
-    }
-
-    /**
-     * Safe reference to a new API on a static inner class.
-     */
-    CharSequence safeGetAccessibilityPaneTitle(View view) {
-        if (Build.VERSION.SDK_INT >= 28) {
-            return Api28Impl.getAccessibilityPaneTitle(view);
-        } else {
-            return null;
-        }
-    }
-
-    @RequiresApi(28)
-    static class Api28Impl {
-
-        private Api28Impl() {
-            // Not instantiable.
-        }
-
-        public static CharSequence getAccessibilityPaneTitle(View view) {
-            return view.getAccessibilityPaneTitle();
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/RequiresApiJava.java b/lint-checks/integration-tests/src/main/java/androidx/RequiresApiJava.java
deleted file mode 100644
index f90b49b..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/RequiresApiJava.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2021 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 androidx;
-
-import androidx.annotation.RequiresApi;
-
-/**
- * Character.isSurrogate requires API 19. Prior to addressing b/202415535, lint did not detect the
- * presence of @RequiresApi annotation in an outer class, and incorrectly flagged MyStaticClass
- * as needing a @RequiresApi annotation.
- */
-@RequiresApi(19)
-final class RequiresApiJava {
-
-    // RequiresApi annotation should not be needed here; already present in containing class
-    // @RequiresApi(19)
-    public static final class MyStaticClass {
-
-        private MyStaticClass() {
-            // Not instantiable.
-        }
-
-        static int myStaticMethod(char c) {
-            Character.isSurrogate(c);
-            return 0;
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/RequiresApiKotlin.kt b/lint-checks/integration-tests/src/main/java/androidx/RequiresApiKotlin.kt
deleted file mode 100644
index a8cf4d0..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/RequiresApiKotlin.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Copyright 2021 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 androidx
-
-import androidx.annotation.RequiresApi
-
-/*
- * Character.isSurrogate requires API 19. Prior to addressing b/202415535, the
- * ClassVerificationFailure detector only checked for the @RequiresApi annotation on a method's
- * immediate containing class, and did not factor in @RequiresApi annotations on outer class(es).
- *
- * This sample file covers various cases of nested @RequiresApi usage.
- */
-
-// RequiresApi annotation not needed on MyStaticClass since it's already present in containing class
-@RequiresApi(19)
-internal class RequiresApiKotlinOuter19Passes {
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-// @RequiresApi declaration on MyStaticMethod's immediate containing class
-internal class RequiresApiKotlinInner19Passes {
-    @RequiresApi(19)
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-// Even though MyStaticClass declares a @RequiresApi that is too low, the outer containing class's
-// definition of 19 will override it.
-@RequiresApi(19)
-internal class RequiresApiKotlinInner16Outer19Passes {
-    @RequiresApi(16)
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-internal class RequiresApiKotlinNoAnnotationFails {
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-@RequiresApi(16)
-internal class RequiresApiKotlinOuter16Fails {
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-internal class RequiresApiKotlinInner16Fails {
-    @RequiresApi(16)
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
-
-@RequiresApi(16)
-internal class RequiresApiKotlinInner16Outer16Fails {
-    @RequiresApi(16)
-    object MyStaticClass {
-        fun MyStaticMethod(c: Char): Int {
-            Character.isSurrogate(c)
-            return 0
-        }
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/sample/appcompat/widget/ActionBarBackgroundDrawable.java b/lint-checks/integration-tests/src/main/java/androidx/sample/appcompat/widget/ActionBarBackgroundDrawable.java
deleted file mode 100644
index 6fe87e2..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/sample/appcompat/widget/ActionBarBackgroundDrawable.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright (C) 2015 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 androidx.sample.appcompat.widget;
-
-import android.graphics.Canvas;
-import android.graphics.ColorFilter;
-import android.graphics.Outline;
-import android.graphics.PixelFormat;
-import android.graphics.drawable.Drawable;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RequiresApi;
-
-@SuppressWarnings({"unused", "deprecation"})
-class ActionBarBackgroundDrawable extends Drawable {
-
-    final ActionBarContainerStub mContainer;
-
-    ActionBarBackgroundDrawable(ActionBarContainerStub container) {
-        mContainer = container;
-    }
-
-    @Override
-    public void draw(@NonNull Canvas canvas) {
-        if (mContainer.mIsSplit) {
-            if (mContainer.mSplitBackground != null) {
-                mContainer.mSplitBackground.draw(canvas);
-            }
-        } else {
-            if (mContainer.mBackground != null) {
-                mContainer.mBackground.draw(canvas);
-            }
-            if (mContainer.mStackedBackground != null && mContainer.mIsStacked) {
-                mContainer.mStackedBackground.draw(canvas);
-            }
-        }
-    }
-
-    @Override
-    public void setAlpha(int alpha) {
-    }
-
-    @Override
-    public void setColorFilter(ColorFilter cf) {
-    }
-
-    @Override
-    public int getOpacity() {
-        return PixelFormat.UNKNOWN;
-    }
-
-    @Override
-    @RequiresApi(21)
-    public void getOutline(@NonNull Outline outline) {
-        if (mContainer.mIsSplit) {
-            if (mContainer.mSplitBackground != null) {
-                mContainer.mSplitBackground.getOutline(outline);
-            }
-        } else {
-            // ignore the stacked background for shadow casting
-            if (mContainer.mBackground != null) {
-                mContainer.mBackground.getOutline(outline);
-            }
-        }
-    }
-
-    // Stub implementation to avoid appcompat dependency.
-    static class ActionBarContainerStub {
-        Drawable mBackground;
-        Drawable mSplitBackground;
-        Drawable mStackedBackground;
-
-        boolean mIsSplit;
-        boolean mIsStacked;
-    }
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompat.java b/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompat.java
deleted file mode 100644
index 216ef20..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompat.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * Copyright 2021 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 androidx.sample.core.widget;
-
-import android.os.Build;
-import android.view.View;
-import android.widget.ListView;
-
-import androidx.annotation.NonNull;
-
-/**
- * Helper for accessing features in {@link ListView}
- */
-public class ListViewCompat {
-
-    /**
-     * Scrolls the list items within the view by a specified number of pixels.
-     *
-     * @param listView the list to scroll
-     * @param y the amount of pixels to scroll by vertically
-     */
-    public static void scrollListBy(@NonNull ListView listView, int y) {
-        if (Build.VERSION.SDK_INT >= 19) {
-            // Call the framework version directly
-            listView.scrollListBy(y);
-        } else {
-            // provide backport on earlier versions
-            final int firstPosition = listView.getFirstVisiblePosition();
-            if (firstPosition == ListView.INVALID_POSITION) {
-                return;
-            }
-
-            final View firstView = listView.getChildAt(0);
-            if (firstView == null) {
-                return;
-            }
-
-            final int newTop = firstView.getTop() - y;
-            listView.setSelectionFromTop(firstPosition, newTop);
-        }
-    }
-
-    /**
-     * Check if the items in the list can be scrolled in a certain direction.
-     *
-     * @param direction Negative to check scrolling up, positive to check
-     *            scrolling down.
-     * @return true if the list can be scrolled in the specified direction,
-     *         false otherwise.
-     * @see #scrollListBy(ListView, int)
-     */
-    public static boolean canScrollList(@NonNull ListView listView, int direction) {
-        if (Build.VERSION.SDK_INT >= 19) {
-            // Call the framework version directly
-            return listView.canScrollList(direction);
-        } else {
-            // provide backport on earlier versions
-            final int childCount = listView.getChildCount();
-            if (childCount == 0) {
-                return false;
-            }
-
-            final int firstPosition = listView.getFirstVisiblePosition();
-            if (direction > 0) {
-                final int lastBottom = listView.getChildAt(childCount - 1).getBottom();
-                final int lastPosition = firstPosition + childCount;
-                return lastPosition < listView.getCount()
-                        || (lastBottom > listView.getHeight() - listView.getListPaddingBottom());
-            } else {
-                final int firstTop = listView.getChildAt(0).getTop();
-                return firstPosition > 0 || firstTop < listView.getListPaddingTop();
-            }
-        }
-    }
-
-    private ListViewCompat() {}
-}
diff --git a/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompatKotlin.kt b/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompatKotlin.kt
deleted file mode 100644
index d5e62a0..0000000
--- a/lint-checks/integration-tests/src/main/java/androidx/sample/core/widget/ListViewCompatKotlin.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Copyright 2021 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 androidx.sample.core.widget
-
-import android.os.Build
-import android.widget.ListView
-
-@Suppress("unused")
-object ListViewCompatKotlin {
-    /**
-     * Scrolls the list items within the view by a specified number of pixels.
-     *
-     * @param listView the list to scroll
-     * @param y the amount of pixels to scroll by vertically
-     */
-    fun scrollListBy(listView: ListView, y: Int) {
-        if (Build.VERSION.SDK_INT >= 19) {
-            // Call the framework version directly
-            listView.scrollListBy(y)
-        } else {
-            // provide backport on earlier versions
-            val firstPosition = listView.firstVisiblePosition
-            if (firstPosition == ListView.INVALID_POSITION) {
-                return
-            }
-            val firstView = listView.getChildAt(0) ?: return
-            val newTop = firstView.top - y
-            listView.setSelectionFromTop(firstPosition, newTop)
-        }
-    }
-
-    /**
-     * Check if the items in the list can be scrolled in a certain direction.
-     *
-     * @param direction Negative to check scrolling up, positive to check scrolling down.
-     * @return true if the list can be scrolled in the specified direction, false otherwise.
-     * @see .scrollListBy
-     */
-    fun canScrollList(listView: ListView, direction: Int): Boolean {
-        return if (Build.VERSION.SDK_INT >= 19) {
-            // Call the framework version directly
-            listView.canScrollList(direction)
-        } else {
-            // provide backport on earlier versions
-            val childCount = listView.childCount
-            if (childCount == 0) {
-                return false
-            }
-            val firstPosition = listView.firstVisiblePosition
-            if (direction > 0) {
-                val lastBottom = listView.getChildAt(childCount - 1).bottom
-                val lastPosition = firstPosition + childCount
-                lastPosition < listView.count ||
-                    lastBottom > listView.height - listView.listPaddingBottom
-            } else {
-                val firstTop = listView.getChildAt(0).top
-                firstPosition > 0 || firstTop < listView.listPaddingTop
-            }
-        }
-    }
-}
diff --git a/media/media/build.gradle b/media/media/build.gradle
index 6643b06..ea92180 100644
--- a/media/media/build.gradle
+++ b/media/media/build.gradle
@@ -47,12 +47,6 @@
     buildFeatures {
         aidl = true
     }
-    sourceSets {
-        main.java.srcDirs += [
-        ]
-        main.res.srcDirs += "src/main/res-public"
-    }
-
     buildTypes.configureEach {
         consumerProguardFiles "proguard-rules.pro"
     }
diff --git a/media/media/src/main/res-public/values/public_styles.xml b/media/media/src/main/res/values/public_styles.xml
similarity index 100%
rename from media/media/src/main/res-public/values/public_styles.xml
rename to media/media/src/main/res/values/public_styles.xml
diff --git a/metrics/integration-tests/janktest/build.gradle b/metrics/integration-tests/janktest/build.gradle
index 7b24dd4..6a93329 100644
--- a/metrics/integration-tests/janktest/build.gradle
+++ b/metrics/integration-tests/janktest/build.gradle
@@ -30,11 +30,11 @@
 
 dependencies {
     implementation(libs.kotlinStdlib)
-    implementation project(":recyclerview:recyclerview")
-    implementation project(":navigation:navigation-fragment-ktx")
-    implementation project(":navigation:navigation-ui-ktx")
+    implementation(project(":recyclerview:recyclerview"))
+    implementation(project(":navigation:navigation-fragment-ktx"))
+    implementation(project(":navigation:navigation-ui-ktx"))
     implementation("androidx.appcompat:appcompat:1.1.0")
-    implementation project(":metrics:metrics-performance")
+    implementation(project(":metrics:metrics-performance"))
     implementation("com.google.android.material:material:1.2.1")
     implementation("androidx.constraintlayout:constraintlayout:2.0.1")
 }
diff --git a/navigation/integration-tests/safeargs-testapp/buildSrc/build.gradle b/navigation/integration-tests/safeargs-testapp/buildSrc/build.gradle
index f2f9b2f..54f4282 100644
--- a/navigation/integration-tests/safeargs-testapp/buildSrc/build.gradle
+++ b/navigation/integration-tests/safeargs-testapp/buildSrc/build.gradle
@@ -37,7 +37,7 @@
 
 apply plugin: "java"
 dependencies {
-    compile project(":moar-buildSrc")
+    api(project(":moar-buildSrc"))
 }
 
 tasks["build"].dependsOn unzip
diff --git a/navigation/navigation-compose/build.gradle b/navigation/navigation-compose/build.gradle
index c5f237a..fcb4f68 100644
--- a/navigation/navigation-compose/build.gradle
+++ b/navigation/navigation-compose/build.gradle
@@ -94,12 +94,10 @@
     description = "Compose integration with Navigation"
     samples(project(":navigation:navigation-compose:navigation-compose-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
 
 android {
     compileSdk = 35
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/navigation/navigation-compose"
-
     namespace = "androidx.navigation.compose"
 }
diff --git a/navigation/navigation-compose/src/main/java/androidx/navigation/compose/NavHostController.kt b/navigation/navigation-compose/src/main/java/androidx/navigation/compose/NavHostController.kt
index baeeaa4..0eb9377 100644
--- a/navigation/navigation-compose/src/main/java/androidx/navigation/compose/NavHostController.kt
+++ b/navigation/navigation-compose/src/main/java/androidx/navigation/compose/NavHostController.kt
@@ -17,7 +17,6 @@
 package androidx.navigation.compose
 
 import android.content.Context
-import android.os.Bundle
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.State
@@ -76,7 +75,7 @@
 
 /** Saver to save and restore the NavController across config change and process death. */
 private fun NavControllerSaver(context: Context): Saver<NavHostController, *> =
-    Saver<NavHostController, Bundle>(
+    Saver(
         save = { it.saveState() },
         restore = { createNavController(context).apply { restoreState(it) } }
     )
diff --git a/navigation/navigation-runtime-truth/build.gradle b/navigation/navigation-runtime-truth/build.gradle
deleted file mode 100644
index de14d57..0000000
--- a/navigation/navigation-runtime-truth/build.gradle
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright 2019 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.
- */
-
-/**
- * This file was created using the `create_project.py` script located in the
- * `<AndroidX root>/development/project-creator` directory.
- *
- * Please use that script when creating a new project, rather than copying an existing project and
- * modifying its settings.
- */
-import androidx.build.Publish
-
-plugins {
-    id("AndroidXPlugin")
-    id("com.android.library")
-    id("org.jetbrains.kotlin.android")
-}
-
-dependencies {
-    api(project(":navigation:navigation-runtime"))
-    api(libs.truth)
-    api(libs.kotlinStdlib)
-
-    implementation(project(":navigation:navigation-common"))
-
-    androidTestImplementation(project(":internal-testutils-truth"))
-    androidTestImplementation(project(":internal-testutils-navigation"), {
-        exclude group: "androidx.navigation", module: "navigation-common"
-    })
-    androidTestImplementation(libs.junit)
-    androidTestImplementation(libs.testCore)
-    androidTestImplementation(libs.testRunner)
-    androidTestImplementation(libs.testRules)
-}
-
-androidx {
-    name = "Navigation Runtime Truth"
-    publish = Publish.NONE
-    inceptionYear = "2019"
-    description = "Android Navigation-Runtime-Truth"
-}
-
-android {
-    namespace = "androidx.navigation.truth"
-}
diff --git a/navigation/navigation-runtime-truth/src/androidTest/AndroidManifest.xml b/navigation/navigation-runtime-truth/src/androidTest/AndroidManifest.xml
deleted file mode 100644
index 23f87a3..0000000
--- a/navigation/navigation-runtime-truth/src/androidTest/AndroidManifest.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright (C) 2017 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.
-  -->
-
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
-</manifest>
diff --git a/navigation/navigation-runtime-truth/src/androidTest/java/androidx/navigation/truth/NavControllerSubjectTest.kt b/navigation/navigation-runtime-truth/src/androidTest/java/androidx/navigation/truth/NavControllerSubjectTest.kt
deleted file mode 100644
index 998ffde..0000000
--- a/navigation/navigation-runtime-truth/src/androidTest/java/androidx/navigation/truth/NavControllerSubjectTest.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright 2019 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 androidx.navigation.truth
-
-import androidx.navigation.NavController
-import androidx.navigation.plusAssign
-import androidx.navigation.truth.NavControllerSubject.Companion.assertThat
-import androidx.navigation.truth.test.R
-import androidx.test.annotation.UiThreadTest
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.filters.SmallTest
-import androidx.testutils.TestNavigator
-import androidx.testutils.assertThrows
-import org.junit.Before
-import org.junit.Test
-
-@SmallTest
-class NavControllerSubjectTest {
-    private lateinit var navController: NavController
-
-    @UiThreadTest
-    @Before
-    fun setUp() {
-        navController =
-            NavController(ApplicationProvider.getApplicationContext()).apply {
-                navigatorProvider += TestNavigator()
-                setGraph(R.navigation.test_graph)
-            }
-    }
-
-    @Test
-    fun testIsCurrentDestination() {
-        assertThat(navController).isCurrentDestination(R.id.start_test)
-    }
-
-    @Test
-    fun testIsCurrentDestinationFailure() {
-        with(assertThrows { assertThat(navController).isCurrentDestination(R.id.second_test) }) {
-            factValue("expected id").isEqualTo("0x${R.id.second_test.toString(16)}")
-            factValue("but was")
-                .isEqualTo("0x${navController.currentDestination?.id?.toString(16)}")
-            factValue("current destination is")
-                .isEqualTo(navController.currentDestination.toString())
-        }
-    }
-
-    @Test
-    fun testIsGraph() {
-        assertThat(navController).isGraph(R.id.test_graph)
-    }
-
-    @Test
-    fun testIsGraphFailure() {
-        with(assertThrows { assertThat(navController).isGraph(R.id.second_test_graph) }) {
-            factValue("expected id").isEqualTo("0x${R.id.second_test_graph.toString(16)}")
-            factValue("but was").isEqualTo("0x${navController.graph.id.toString(16)}")
-            factValue("current graph is").isEqualTo(navController.graph.toString())
-        }
-    }
-}
diff --git a/navigation/navigation-runtime-truth/src/androidTest/res/navigation/test_graph.xml b/navigation/navigation-runtime-truth/src/androidTest/res/navigation/test_graph.xml
deleted file mode 100644
index 5ac317b..0000000
--- a/navigation/navigation-runtime-truth/src/androidTest/res/navigation/test_graph.xml
+++ /dev/null
@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  Copyright 2019 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.
-  -->
-<navigation
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    app:startDestination="@+id/start_test"
-    android:id="@+id/test_graph"
-    android:label="test_label">
-
-    <test android:id="@+id/start_test">
-        <action android:id="@+id/second" app:destination="@+id/second_test" />
-    </test>
-
-    <test android:id="@+id/second_test" />
-</navigation>
diff --git a/navigation/navigation-runtime-truth/src/main/java/androidx/navigation/truth/NavControllerSubject.kt b/navigation/navigation-runtime-truth/src/main/java/androidx/navigation/truth/NavControllerSubject.kt
deleted file mode 100644
index 8e23a53..0000000
--- a/navigation/navigation-runtime-truth/src/main/java/androidx/navigation/truth/NavControllerSubject.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright 2019 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 androidx.navigation.truth
-
-import android.annotation.SuppressLint
-import androidx.annotation.IdRes
-import androidx.navigation.NavController
-import com.google.common.truth.Fact.fact
-import com.google.common.truth.FailureMetadata
-import com.google.common.truth.Subject
-import com.google.common.truth.Truth.assertAbout
-
-/** A Truth Subject for making assertions about [NavController]. */
-class NavControllerSubject
-private constructor(metadata: FailureMetadata, private val actual: NavController) :
-    Subject(metadata, actual) {
-
-    /**
-     * Assert that the [NavController] has the given current destination in its
-     * [androidx.navigation.NavGraph].
-     *
-     * @param navDest The ID resource of a [androidx.navigation.NavDestination]
-     */
-    fun isCurrentDestination(@IdRes navDest: Int) {
-        val actualDest = actual.currentDestination?.id
-        if (actualDest != navDest) {
-            failWithoutActual(
-                fact("expected id", "0x${navDest.toString(16)}"),
-                fact("but was", "0x${actualDest?.toString(16)}"),
-                fact("current destination is", actual.currentDestination)
-            )
-        }
-    }
-
-    /**
-     * Assert that the [NavController] has the given [androidx.navigation.NavGraph] as its current
-     * graph.
-     *
-     * @param navGraph The ID resource of a [androidx.navigation.NavGraph]
-     */
-    fun isGraph(@IdRes navGraph: Int) {
-        val actualGraph = actual.graph.id
-        if (actualGraph != navGraph) {
-            failWithoutActual(
-                fact("expected id", "0x${navGraph.toString(16)}"),
-                fact("but was", "0x${actualGraph.toString(16)}"),
-                fact("current graph is", actual.graph)
-            )
-        }
-    }
-
-    companion object {
-        @SuppressLint("MemberVisibilityCanBePrivate")
-        val factory =
-            Factory<NavControllerSubject, NavController> { metadata, actual ->
-                NavControllerSubject(metadata, actual)
-            }
-
-        @JvmStatic
-        fun assertThat(actual: NavController): NavControllerSubject {
-            return assertAbout(factory).that(actual)
-        }
-    }
-}
diff --git a/navigation/navigation-runtime-truth/src/main/res/navigation/second_test_graph.xml b/navigation/navigation-runtime-truth/src/main/res/navigation/second_test_graph.xml
deleted file mode 100644
index 504c3f2..0000000
--- a/navigation/navigation-runtime-truth/src/main/res/navigation/second_test_graph.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  Copyright 2019 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.
-  -->
-
-<navigation xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/second_test_graph">
-
-</navigation>
\ No newline at end of file
diff --git a/navigation3/navigation3/samples/build.gradle b/navigation3/navigation3/samples/build.gradle
index cd517e3..4bc9f73 100644
--- a/navigation3/navigation3/samples/build.gradle
+++ b/navigation3/navigation3/samples/build.gradle
@@ -48,8 +48,8 @@
     implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
     implementation(libs.kotlinSerializationCore)
     implementation("androidx.savedstate:savedstate-ktx:1.3.0-alpha05")
-    implementation project(":lifecycle:lifecycle-viewmodel-navigation3")
-    implementation project(":navigation3:navigation3")
+    implementation(project(":lifecycle:lifecycle-viewmodel-navigation3"))
+    implementation(project(":navigation3:navigation3"))
 
 }
 
diff --git a/paging/paging-compose/build.gradle b/paging/paging-compose/build.gradle
index 01ca1bd..41d4097 100644
--- a/paging/paging-compose/build.gradle
+++ b/paging/paging-compose/build.gradle
@@ -54,7 +54,7 @@
 
         commonTest {
             dependencies {
-                implementation project(":compose:ui:ui-tooling")
+                implementation(project(":compose:ui:ui-tooling"))
                 implementation(project(":compose:test-utils"))
                 implementation(project(":internal-testutils-paging"))
             }
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivityV2.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivityV2.kt
index 1fc9c41..353d61c 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivityV2.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivityV2.kt
@@ -38,7 +38,12 @@
     @VisibleForTesting
     var filePicker: ActivityResultLauncher<String> =
         registerForActivityResult(GetContent()) { uri: Uri? ->
-            uri?.let { pdfViewerFragment?.documentUri = uri }
+            uri?.let {
+                if (pdfViewerFragment == null) {
+                    setPdfView()
+                }
+                pdfViewerFragment?.documentUri = uri
+            }
         }
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -54,9 +59,6 @@
         val getContentButton: MaterialButton = findViewById(R.id.launch_button)
 
         getContentButton.setOnClickListener { filePicker.launch(MIME_TYPE_PDF) }
-        if (savedInstanceState == null) {
-            setPdfView()
-        }
     }
 
     private fun setPdfView() {
diff --git a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
index f40d303..dbc7510 100644
--- a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
+++ b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
@@ -33,6 +33,7 @@
 import junit.framework.TestCase.assertFalse
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -151,6 +152,21 @@
         }
     }
 
+    @Test
+    fun searchDocument_fullDocumentSearch_withSinglePageResults() = runTest {
+        withDocument(PDF_DOCUMENT) { document ->
+            val query = "pages are all the same size"
+            val pageRange = 0..2
+
+            val results = document.searchDocument(query, pageRange)
+
+            // Assert sparse array doesn't contain empty result lists
+            assertEquals(1, results.size())
+            // Assert single result on first page
+            assertEquals(1, results[0].size)
+        }
+    }
+
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     @Test
     fun getSelectionBounds_returnsPageSelection() = runTest {
diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
index 774bb7a..097d35b 100644
--- a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
+++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
@@ -88,11 +88,14 @@
         pageRange: IntRange
     ): SparseArray<List<PageMatchBounds>> {
         return withDocument { document ->
-            pageRange
-                .map { pageNum ->
-                    document.searchPageText(pageNum, query).map { it.toContentClass() }
+            SparseArray<List<PageMatchBounds>>(pageRange.last + 1).apply {
+                pageRange.forEach { pageNum ->
+                    document
+                        .searchPageText(pageNum, query)
+                        .takeIf { it.isNotEmpty() }
+                        ?.let { put(pageNum, it.map { result -> result.toContentClass() }) }
                 }
-                .toSparseArray(pageRange)
+            }
         }
     }
 
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/assets/corrupted.pdf b/pdf/pdf-viewer-fragment/src/androidTest/assets/corrupted.pdf
new file mode 100644
index 0000000..1b76c16
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/androidTest/assets/corrupted.pdf
Binary files differ
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt
index ecb464f..82c5714 100644
--- a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt
+++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt
@@ -16,15 +16,26 @@
 
 package androidx.pdf.viewer.coroutines
 
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 
-suspend fun <T> Flow<T>.toListDuring(durationInMillis: Long): List<T> = coroutineScope {
+internal suspend fun <T> Flow<T>.toListDuring(durationInMillis: Long): List<T> = coroutineScope {
     val result = mutableListOf<T>()
     val job = launch { [email protected](result::add) }
     delay(durationInMillis)
     job.cancel()
     return@coroutineScope result
 }
+
+internal suspend fun <T> Flow<T>.collectTill(
+    result: MutableList<T>,
+    predicate: suspend (value: T) -> Boolean
+) {
+    collect { value ->
+        result.add(value)
+        if (predicate(value)) throw CancellationException()
+    }
+}
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt
index e1ca79d..206433a 100644
--- a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt
+++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt
@@ -16,8 +16,10 @@
 
 package androidx.pdf.viewer.fragment
 
+import android.net.Uri
 import androidx.lifecycle.SavedStateHandle
 import androidx.pdf.SandboxedPdfLoader
+import androidx.pdf.viewer.coroutines.collectTill
 import androidx.pdf.viewer.coroutines.toListDuring
 import androidx.pdf.viewer.fragment.TestUtils.openFileAsUri
 import androidx.pdf.viewer.fragment.model.PdfFragmentUiState
@@ -110,6 +112,92 @@
         assertTrue(pdfViewModel.fragmentUiScreenState.value !is PdfFragmentUiState.Loading)
     }
 
-    // TODO: Add tests for password-protected pdf and corrupted pdf after test-artifact b/379743760
+    @Test
+    fun test_pdfDocumentViewModel_loadDocumentFailure_corruptedPdf() = runTest {
+        val documentUri = openFileAsUri(appContext, CORRUPTED_PDF)
 
+        val uiStates = mutableListOf<PdfFragmentUiState>()
+        // start collecting Ui states
+        val collectJob = launch {
+            pdfDocumentViewModel.fragmentUiScreenState.collectTill(uiStates) { state ->
+                state is PdfFragmentUiState.DocumentError
+            }
+        }
+        // load pdf document
+        pdfDocumentViewModel.loadDocument(documentUri, null)
+
+        // wait till collection is completed
+        collectJob.join()
+
+        // Since we've selected a corrupted unprotected pdf,
+        // ideally there should only 2 states transitions.
+        assertTrue(uiStates.size == 2)
+        // Assert the first state emitted was loading
+        assertTrue(uiStates.first() is PdfFragmentUiState.Loading)
+        // Assert the last state emitted was Document error
+        assertTrue(
+            pdfDocumentViewModel.fragmentUiScreenState.value is PdfFragmentUiState.DocumentError
+        )
+    }
+
+    @Test
+    fun test_pdfDocumentViewModel_loadDocumentFailure_invalidUriPath() = runTest {
+        val documentUri =
+            Uri.parse("file:///data/data/com.example.app/invalid/path/to/nonexistent/file.pdf")
+
+        val uiStates = mutableListOf<PdfFragmentUiState>()
+        // start collecting Ui states
+        val collectJob = launch {
+            pdfDocumentViewModel.fragmentUiScreenState.collectTill(uiStates) { state ->
+                state is PdfFragmentUiState.DocumentError
+            }
+        }
+        // load pdf document
+        pdfDocumentViewModel.loadDocument(documentUri, null)
+
+        // wait till collection is completed
+        collectJob.join()
+
+        // Since we've selected a invalid Uri Path,
+        // ideally there should only 2 states transitions.
+        assertTrue(uiStates.size == 2)
+        // Assert the first state emitted was loading
+        assertTrue(uiStates.first() is PdfFragmentUiState.Loading)
+        // Assert the last state emitted was Document error
+        assertTrue(
+            pdfDocumentViewModel.fragmentUiScreenState.value is PdfFragmentUiState.DocumentError
+        )
+    }
+
+    @Test
+    fun test_pdfDocumentViewModel_loadDocumentFailure_invalidUriScheme() = runTest {
+        val documentUri = Uri.parse("xyz://path/to/sample.pdf")
+
+        val uiStates = mutableListOf<PdfFragmentUiState>()
+        // start collecting Ui states
+        val collectJob = launch {
+            pdfDocumentViewModel.fragmentUiScreenState.collectTill(uiStates) { state ->
+                state is PdfFragmentUiState.DocumentError
+            }
+        }
+        // load pdf document
+        pdfDocumentViewModel.loadDocument(documentUri, null)
+
+        // wait till collection is completed
+        collectJob.join()
+
+        // Since we've selected a invalid Uri Scheme,
+        // ideally there should only 2 states transitions.
+        assertTrue(uiStates.size == 2)
+        // Assert the first state emitted was loading
+        assertTrue(uiStates.first() is PdfFragmentUiState.Loading)
+        // Assert the last state emitted was Document error
+        assertTrue(
+            pdfDocumentViewModel.fragmentUiScreenState.value is PdfFragmentUiState.DocumentError
+        )
+    }
+
+    companion object {
+        private const val CORRUPTED_PDF = "corrupted.pdf"
+    }
 }
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
index c057893..8d3c566 100644
--- a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
@@ -21,6 +21,10 @@
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import android.widget.LinearLayout.GONE
+import android.widget.LinearLayout.VISIBLE
+import android.widget.ProgressBar
+import android.widget.TextView
 import androidx.annotation.CallSuper
 import androidx.annotation.RestrictTo
 import androidx.fragment.app.Fragment
@@ -128,6 +132,8 @@
     }
 
     private lateinit var pdfView: PdfView
+    private lateinit var errorView: TextView
+    private lateinit var loadingView: ProgressBar
 
     override fun onCreateView(
         inflater: LayoutInflater,
@@ -137,6 +143,8 @@
         super.onCreateView(inflater, container, savedInstanceState)
         val root = inflater.inflate(R.layout.pdf_viewer_fragment, container, false)
         pdfView = root.findViewById(R.id.pdfView)
+        errorView = root.findViewById(R.id.errorTextView)
+        loadingView = root.findViewById(R.id.pdfLoadingProgressBar)
 
         return root
     }
@@ -163,25 +171,37 @@
     private suspend fun collectFragmentUiScreenState() {
         documentViewModel.fragmentUiScreenState.collect { uiState ->
             when (uiState) {
-                is Loading -> {
-                    // TODO: Implement loading view b/379226011
-                    // Hide all views except loading progress bar
-                    // Show progress bar
-                }
-                is PasswordRequested -> {
-                    // TODO: Implement password dialog b/373252814
-                    // Utilize retry param to show incorrect password on PasswordDialog
-                }
-                is DocumentLoaded -> {
-                    onLoadDocumentSuccess()
-                    pdfView.pdfDocument = uiState.pdfDocument
-                    // TODO: Implement PdfView b/379053734
-                }
-                is DocumentError -> {
-                    onLoadDocumentError(uiState.exception)
-                    // TODO: Implement error view b/379055053
-                }
+                is Loading -> handleLoading()
+                is PasswordRequested -> handlePasswordRequested()
+                is DocumentLoaded -> handleDocumentLoaded(uiState)
+                is DocumentError -> handleDocumentError(uiState)
             }
         }
     }
+
+    private fun handleLoading() {
+        setViewVisibility(pdfView = GONE, loadingView = VISIBLE, errorView = GONE)
+    }
+
+    private fun handlePasswordRequested() {
+        setViewVisibility(pdfView = GONE, loadingView = GONE, errorView = GONE)
+        // Utilize retry param to show incorrect password on PasswordDialog
+    }
+
+    private fun handleDocumentLoaded(uiState: DocumentLoaded) {
+        onLoadDocumentSuccess()
+        pdfView.pdfDocument = uiState.pdfDocument
+        setViewVisibility(pdfView = VISIBLE, loadingView = GONE, errorView = GONE)
+    }
+
+    private fun handleDocumentError(uiState: DocumentError) {
+        onLoadDocumentError(uiState.exception)
+        setViewVisibility(pdfView = GONE, loadingView = GONE, errorView = VISIBLE)
+    }
+
+    private fun setViewVisibility(pdfView: Int, loadingView: Int, errorView: Int) {
+        this.pdfView.visibility = pdfView
+        this.loadingView.visibility = loadingView
+        this.errorView.visibility = errorView
+    }
 }
diff --git a/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml b/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml
index 4527500..e91c0c6 100644
--- a/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml
+++ b/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml
@@ -28,4 +28,30 @@
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
+    <!-- ProgressBar for loading -->
+    <ProgressBar
+        android:id="@+id/pdfLoadingProgressBar"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerInParent="true"
+        android:visibility="gone"
+        android:indeterminate="true"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/errorTextView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/error_cannot_open_pdf"
+        android:textColor="?attr/colorOnSurface"
+        android:textAppearance="?attr/textAppearanceBodyMedium"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        android:visibility="gone" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/pdf/pdf-viewer/build.gradle b/pdf/pdf-viewer/build.gradle
index 7581404..499d012 100644
--- a/pdf/pdf-viewer/build.gradle
+++ b/pdf/pdf-viewer/build.gradle
@@ -30,6 +30,7 @@
     implementation(libs.kotlinStdlib)
     implementation("androidx.exifinterface:exifinterface:1.3.2")
     implementation("androidx.core:core:1.13.0")
+    implementation("androidx.customview:customview:1.2.0-alpha02")
     implementation("androidx.annotation:annotation:1.7.0")
     implementation("com.google.android.material:material:1.11.0")
     implementation("com.google.errorprone:error_prone_annotations:2.30.0")
@@ -59,6 +60,7 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.kotlinCoroutinesTest)
     androidTestImplementation(libs.mockitoKotlin4)
+    androidTestImplementation(libs.espressoIntents)
 }
 
 android {
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
index 1b39c3ec..4c0c976 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
@@ -17,11 +17,9 @@
 package androidx.pdf.search
 
 import android.util.SparseArray
-import androidx.core.util.isEmpty
-import androidx.core.util.isNotEmpty
 import androidx.pdf.content.PageMatchBounds
-import androidx.pdf.search.model.SearchResults
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.NoQuery
+import androidx.pdf.search.model.QueryResults
 import androidx.pdf.view.FakePdfDocument
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
@@ -59,38 +57,42 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 5)
+            produceSearchResults(query = "test", currentVisiblePage = 5)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(3, results.resultBounds.size())
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on same page
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(1, selectedSearchResult.value?.currentIndex)
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(1, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on next page
             // in forward direction
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch previous result
-            prev()
+            producePreviousResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to previous result cyclically
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -101,19 +103,20 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 7)
+            produceSearchResults(query = "test", currentVisiblePage = 7)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(3, results.resultBounds.size())
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -124,20 +127,21 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
+            assertEquals(3, results.resultBounds.size())
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on next page
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -148,46 +152,47 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
+            val results = queryResults.value
 
             // Assert no results returned
-            assertEquals(0, results.results.size())
+            assertTrue(results is QueryResults.NoMatch)
+            assertEquals("test", (results as QueryResults.NoMatch).query)
         }
     }
 
     @Test(expected = NoSuchElementException::class)
-    fun testPrevOperation_noMatchingResults() = runTest {
+    fun testFindPrevOperation_noMatchingResults() = runTest {
         val fakeResults = createFakeSearchResults()
         val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
-            assertEquals(0, results.results.size())
+            val results = queryResults.value
+            assertTrue(results is QueryResults.NoMatch)
 
             // fetch previous result, should throw [NoSuchElementException]
-            prev()
+            producePreviousResult()
         }
     }
 
     @Test(expected = NoSuchElementException::class)
-    fun testNextOperation_noMatchingResults() = runTest {
+    fun testFindNextOperation_noMatchingResults() = runTest {
         val fakeResults = createFakeSearchResults()
         val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 10)
+            produceSearchResults(query = "test", currentVisiblePage = 10)
 
-            val results = searchResults.value
-            assertEquals(0, results.results.size())
+            val results = queryResults.value
+            assertTrue(results is QueryResults.NoMatch)
 
             // fetch next result, should throw [NoSuchElementException]
-            next()
+            produceNextResult()
         }
     }
 
@@ -198,67 +203,16 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            assertEquals(3, searchResults.value.results.size())
+            val results = queryResults.value as QueryResults.Matched
+            assertEquals(3, results.resultBounds.size())
 
             // clear results
             clearSearchResults()
 
             // assert results are cleared
-            assertTrue(searchResults.value.results.isEmpty())
-        }
-    }
-
-    @Test
-    fun testSettingStateToRepository() = runTest {
-        val fakeResults = createFakeSearchResults()
-        val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
-        val currentVisiblePage = 11
-
-        with(SearchRepository(fakePdfDocument)) {
-            // search document
-            searchDocument(query = "test", currentVisiblePage = currentVisiblePage)
-
-            // assert there are no results
-            assertEquals(0, searchResults.value.results.size())
-
-            // set results
-            setState(
-                searchResults = SearchResults("test", createFakeSearchResults(1, 5, 5, 10)),
-                selectedSearchResult = SelectedSearchResult(5, 1),
-                currentVisiblePage = currentVisiblePage
-            )
-
-            // assert results are set
-            assertTrue(searchResults.value.results.isNotEmpty())
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(1, selectedSearchResult.value?.currentIndex)
-        }
-    }
-
-    @Test(expected = NoSuchElementException::class)
-    fun testSettingEmptyResultsToRepository() = runTest {
-        val fakeResults = createFakeSearchResults()
-        val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
-        val currentVisiblePage = 11
-
-        with(SearchRepository(fakePdfDocument)) {
-            // search document
-            searchDocument(query = "test", currentVisiblePage = currentVisiblePage)
-
-            // assert there are no results
-            assertEquals(0, searchResults.value.results.size())
-
-            // set results
-            setState(
-                searchResults = SearchResults("test", SparseArray()),
-                selectedSearchResult = null,
-                currentVisiblePage = currentVisiblePage
-            )
-
-            // fetch next result, should throw [NoSuchElementException]
-            next()
+            assertTrue(queryResults.value is NoQuery)
         }
     }
 }
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/util/IntentUtils.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/util/IntentUtils.kt
new file mode 100644
index 0000000..3160074
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/util/IntentUtils.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 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 androidx.pdf.util
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+
+/**
+ * Waits for the intent to be fired by polling repeatedly. This is useful when checking for an
+ * intent that may take some time to be triggered. The function retries every 100 ms until the
+ * intent is captured or the [timeoutMillis] duration has passed.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun waitForIntent(timeoutMillis: Long = 1000, checkIntent: () -> Unit) {
+    withContext(Dispatchers.Default.limitedParallelism(1)) {
+        withTimeout(timeoutMillis) {
+            var intentCaptured = false
+            while (!intentCaptured) {
+                try {
+                    checkIntent()
+                    intentCaptured = true
+                } catch (e: AssertionError) {
+                    delay(100)
+                }
+            }
+        }
+    }
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
index 2c5993f..3436827 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
@@ -65,6 +65,7 @@
     override val isLinearized: Boolean = false,
     private val searchResults: SparseArray<List<PageMatchBounds>> = SparseArray(),
     override val uri: Uri = Uri.parse("content://test.app/document.pdf"),
+    private val pageLinks: List<PdfDocument.PdfPageLinks> = emptyList()
 ) : PdfDocument {
     override val pageCount: Int = pages.size
 
@@ -84,8 +85,11 @@
     }
 
     override suspend fun getPageLinks(pageNumber: Int): PdfDocument.PdfPageLinks {
-        // TODO(b/376136907) provide a useful implementation when it's needed for testing
-        return PdfDocument.PdfPageLinks(listOf(), listOf())
+        return if (pageNumber < pageLinks.size) {
+            pageLinks[pageNumber]
+        } else {
+            PdfDocument.PdfPageLinks(emptyList(), emptyList())
+        }
     }
 
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
@@ -162,12 +166,12 @@
                 if (tileRegion == null) {
                     _bitmapRequests[pageNumber] = FullBitmap(scaledPageSizePx)
                     // Tiling, and this is a new rect for a tile board we're already tracking
-                } else if (requestedSize != null && requestedSize is TileBoard) {
+                } else if (requestedSize != null && requestedSize is Tiles) {
                     requestedSize.withTile(tileRegion)
                     // Tiling, and this is the first rect requested
                 } else {
                     _bitmapRequests[pageNumber] =
-                        TileBoard(scaledPageSizePx).apply { withTile(tileRegion) }
+                        Tiles(scaledPageSizePx).apply { withTile(tileRegion) }
                 }
             }
         }
@@ -185,7 +189,7 @@
 internal class FullBitmap(scaledPageSizePx: Size) : SizeParams(scaledPageSizePx)
 
 /** Represents a set of tile region [Bitmap] requested from [PdfDocument.BitmapSource] */
-internal class TileBoard(scaledPageSizePx: Size) : SizeParams(scaledPageSizePx) {
+internal class Tiles(scaledPageSizePx: Size) : SizeParams(scaledPageSizePx) {
     private val _tiles = mutableListOf<Rect>()
     val tiles: List<Rect>
         get() = _tiles
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewActions.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewActions.kt
index 0a09c2f..6ee67c8 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewActions.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewActions.kt
@@ -17,11 +17,14 @@
 package androidx.pdf.view
 
 import android.graphics.PointF
+import android.os.SystemClock
+import android.view.MotionEvent
 import android.view.View
 import androidx.test.espresso.UiController
 import androidx.test.espresso.ViewAction
 import androidx.test.espresso.ViewInteraction
 import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
 import org.hamcrest.Matcher
 import org.hamcrest.Matchers
 
@@ -119,3 +122,55 @@
         uiController.loopMainThreadUntilIdle()
     }
 }
+
+/**
+ * Performs a [ViewAction] that results in single tap on a specific location (x, y) relative to a
+ * given view. This action calculates the screen coordinates of the view and offsets them by the
+ * provided (x, y) values to simulate a tap at the desired position on the screen.
+ *
+ * @param x The horizontal offset (in pixels) from the top-left corner of the view.
+ * @param y The vertical offset (in pixels) from the top-left corner of the view.
+ * @return A ViewAction that can be used with Espresso to perform the tap.
+ */
+internal fun performSingleTapOnCoords(x: Float, y: Float): ViewAction {
+    return object : ViewAction {
+        override fun getConstraints() = isDisplayed()
+
+        override fun getDescription() = "Single tap at coordinates ($x, $y)"
+
+        override fun perform(uiController: UiController, view: View) {
+            check(view is PdfView)
+            val adjustedX = (x * view.zoom) + view.scrollX
+            val adjustedY = (y * view.zoom) + view.scrollY
+
+            val screenCoords = IntArray(2)
+            view.getLocationOnScreen(screenCoords)
+
+            val screenX = screenCoords[0] + adjustedX
+            val screenY = screenCoords[1] + adjustedY
+
+            val downEvent =
+                MotionEvent.obtain(
+                    SystemClock.uptimeMillis(),
+                    SystemClock.uptimeMillis(),
+                    MotionEvent.ACTION_DOWN,
+                    screenX,
+                    screenY,
+                    0
+                )
+
+            val upEvent =
+                MotionEvent.obtain(
+                    SystemClock.uptimeMillis(),
+                    SystemClock.uptimeMillis() + 100,
+                    MotionEvent.ACTION_UP,
+                    screenX,
+                    screenY,
+                    0
+                )
+
+            view.dispatchTouchEvent(downEvent)
+            view.dispatchTouchEvent(upEvent)
+        }
+    }
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewNavigationTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewNavigationTest.kt
new file mode 100644
index 0000000..fad5666
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewNavigationTest.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2024 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 androidx.pdf.view
+
+import android.content.Intent
+import android.graphics.Point
+import android.graphics.RectF
+import android.net.Uri
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PdfPageGotoLinkContent
+import androidx.pdf.content.PdfPageLinkContent
+import androidx.pdf.util.waitForIntent
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewNavigationTest {
+    @After
+    fun tearDown() {
+        PdfViewTestActivity.onCreateCallback = {}
+    }
+
+    @Ignore("Ignored due to flakiness. Pending fix.")
+    @Test
+    fun testGotoLinkNavigation() {
+        // TODO(b/384644788): Fix flakiness in goto link navigation
+        val fakePdfDocument =
+            FakePdfDocument(
+                pages = List(2) { Point(1000, 1000) },
+                pageLinks =
+                    listOf(
+                        PdfDocument.PdfPageLinks(
+                            gotoLinks =
+                                listOf(
+                                    PdfPageGotoLinkContent(
+                                        bounds = listOf(RectF(0f, 0f, 1000f, 1000f)),
+                                        destination =
+                                            PdfPageGotoLinkContent.Destination(
+                                                pageNumber = 1,
+                                                xCoordinate = 100f,
+                                                yCoordinate = 1400f,
+                                                zoom = 1f
+                                            )
+                                    )
+                                ),
+                            externalLinks = emptyList()
+                        )
+                    )
+            )
+        PdfViewTestActivity.onCreateCallback = { activity ->
+            val container = FrameLayout(activity)
+            container.addView(
+                PdfView(activity).apply {
+                    pdfDocument = fakePdfDocument
+                    id = PDF_VIEW_ID
+                },
+                ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.WRAP_CONTENT,
+                    ViewGroup.LayoutParams.MATCH_PARENT
+                )
+            )
+            activity.setContentView(container)
+        }
+
+        with(ActivityScenario.launch(PdfViewTestActivity::class.java)) {
+            Espresso.onView(withId(PDF_VIEW_ID))
+                .perform(performSingleTapOnCoords(100f, 100f))
+                .check { view, noViewFoundException ->
+                    view ?: throw noViewFoundException
+                    val pdfView = view as PdfView
+                    val firstVisiblePage = pdfView.firstVisiblePage
+                    val visiblePagesCount = pdfView.visiblePagesCount
+                    val targetPage = 1
+                    assertThat(targetPage).isAtLeast(firstVisiblePage)
+                    assertThat(targetPage).isAtMost(firstVisiblePage + visiblePagesCount - 1)
+                }
+            close()
+        }
+    }
+
+    @Ignore("Test is skipped due to challenges in testing external intent matching. Pending fix.")
+    @Test
+    fun testExternalLinkNavigation() = runTest {
+        // TODO(b/384644788): Fix intent resolution in external links handling
+        val fakePdfDocument =
+            FakePdfDocument(
+                pages = List(5) { Point(1000, 1000) },
+                pageLinks =
+                    listOf(
+                        PdfDocument.PdfPageLinks(
+                            gotoLinks = emptyList(),
+                            externalLinks =
+                                listOf(
+                                    PdfPageLinkContent(
+                                        bounds = listOf(RectF(0f, 0f, 200f, 200f)),
+                                        uri = Uri.parse("https://www.example.com")
+                                    )
+                                )
+                        )
+                    )
+            )
+
+        Intents.init()
+        PdfViewTestActivity.onCreateCallback = { activity ->
+            val container = FrameLayout(activity)
+            container.addView(
+                PdfView(activity).apply {
+                    pdfDocument = fakePdfDocument
+                    id = PDF_VIEW_ID
+                },
+                ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    ViewGroup.LayoutParams.MATCH_PARENT
+                )
+            )
+            activity.setContentView(container)
+        }
+
+        with(ActivityScenario.launch(PdfViewTestActivity::class.java)) {
+            Espresso.onView(withId(PDF_VIEW_ID)).perform(performSingleTapOnCoords(50f, 50f))
+            waitForIntent {
+                Intents.intended(hasAction(Intent.ACTION_VIEW))
+                Intents.intended(hasData(Uri.parse("https://www.example.com")))
+            }
+            close()
+        }
+        Intents.release()
+    }
+}
+
+/** Arbitrary fixed ID for PdfView */
+private const val PDF_VIEW_ID = 123456789
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
index 8b7895a..8a16283 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
@@ -19,7 +19,7 @@
 import android.util.SparseArray
 import androidx.annotation.RestrictTo
 import androidx.pdf.content.PageMatchBounds
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.QueryResultsIndex
 
 /**
  * A cyclic iterator implementation over SparseArray.
@@ -54,13 +54,13 @@
     }
 
     /** Get the current state of selected search result. */
-    fun current(): SelectedSearchResult {
+    fun current(): QueryResultsIndex {
         val currentPageNum = pageNumList[pageNumIndex]
-        return SelectedSearchResult(currentPageNum, searchIndexOnPage)
+        return QueryResultsIndex(pageNum = currentPageNum, resultBoundsIndex = searchIndexOnPage)
     }
 
     /** Move to the nex element in the current page, or to the next page cyclically. */
-    fun next(): SelectedSearchResult {
+    fun next(): QueryResultsIndex {
         if (totalPages == 0) {
             throw NoSuchElementException("No elements to iterate.")
         }
@@ -80,7 +80,7 @@
     }
 
     /** Move to the previous element in the page list, or to the previous page cyclically. */
-    fun prev(): SelectedSearchResult {
+    fun prev(): QueryResultsIndex {
         if (totalPages == 0) {
             throw NoSuchElementException("No elements to iterate.")
         }
@@ -94,6 +94,8 @@
         // If we're at the beginning of the current page, move to the previous page
         if (searchIndexOnPage == resultsOnPage.size - 1) {
             pageNumIndex = (pageNumIndex - 1 + totalPages) % totalPages
+            // update the search index of page to last result on updated page
+            searchIndexOnPage = searchData.valueAt(pageNumIndex).lastIndex
         }
 
         return current()
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
index 80cce7e..3de7dbb 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
@@ -17,11 +17,11 @@
 package androidx.pdf.search
 
 import androidx.annotation.RestrictTo
-import androidx.core.util.isEmpty
 import androidx.core.util.isNotEmpty
 import androidx.pdf.PdfDocument
-import androidx.pdf.search.model.SearchResults
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.NoQuery
+import androidx.pdf.search.model.QueryResults
+import androidx.pdf.search.model.SearchResultState
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,23 +46,17 @@
  *   to Dispatcher.IO.
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SearchRepository(
+public class SearchRepository(
     private val pdfDocument: PdfDocument,
+    // TODO(b/384001800) Remove dispatcher
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
 ) {
 
-    private val _searchResults: MutableStateFlow<SearchResults> = MutableStateFlow(SearchResults())
+    private val _queryResults: MutableStateFlow<SearchResultState> = MutableStateFlow(NoQuery)
 
     /** Stream of search results for a given query. */
-    val searchResults: StateFlow<SearchResults>
-        get() = _searchResults.asStateFlow()
-
-    private val _selectedSearchResult: MutableStateFlow<SelectedSearchResult?> =
-        MutableStateFlow(null)
-
-    /** Stream of selected search results. */
-    val selectedSearchResult: StateFlow<SelectedSearchResult?>
-        get() = _selectedSearchResult.asStateFlow()
+    public val queryResults: StateFlow<SearchResultState>
+        get() = _queryResults.asStateFlow()
 
     private lateinit var cyclicIterator: CyclicSparseArrayIterator
 
@@ -71,93 +65,108 @@
      *
      * @param query: The search query string.
      * @param currentVisiblePage: Provides current visible document page, which is required to
-     *   search from specific page and to calculate initial [selectedSearchResult]
+     *   search from specific page and to calculate initial QueryResultsIndex.
      *
-     * Results would be updated to [searchResults] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      */
-    suspend fun searchDocument(query: String, currentVisiblePage: Int) {
-        if (query.isEmpty()) return
+    public suspend fun produceSearchResults(query: String, currentVisiblePage: Int) {
+        if (query.isBlank()) {
+            clearSearchResults()
+            return
+        }
 
-        // Clear the existing results
-        clearSearchResults()
+        val searchPageRange = IntRange(start = 0, endInclusive = pdfDocument.pageCount - 1)
 
         // search should be a background work, move execution on to provided [dispatcher]
         // to make [searchDocument] main-safe
-        val currentResult =
+        val searchResults =
             withContext(dispatcher) {
-                SearchResults(
-                    searchQuery = query,
-                    results =
-                        pdfDocument.searchDocument(
-                            query = query,
-                            pageRange = IntRange(start = 0, endInclusive = pdfDocument.pageCount)
-                        )
-                )
+                pdfDocument.searchDocument(query = query, pageRange = searchPageRange)
             }
 
-        // update results
-        _searchResults.update { currentResult }
+        val queryResults =
+            if (searchResults.isNotEmpty()) {
+                /*
+                 When search results are available for a query, we initialize a cyclic iterator.
+                 This iterator is used to traverse the results when `findPrev()` and `findNext()` are called.
+                */
+                cyclicIterator = CyclicSparseArrayIterator(searchResults, currentVisiblePage)
 
-        if (currentResult.results.isNotEmpty()) {
-            // Init cyclic iterator
-            cyclicIterator = CyclicSparseArrayIterator(currentResult.results, currentVisiblePage)
+                QueryResults.Matched(
+                    query = query,
+                    pageRange = searchPageRange,
+                    resultBounds = searchResults,
+                    /* Set [queryResultsIndex] to cyclicIterator.current() which points to first result
+                    on or nearest page to currentVisiblePage in forward direction. */
+                    queryResultsIndex = cyclicIterator.current()
+                )
+            } else {
+                QueryResults.NoMatch(query = query, pageRange = searchPageRange)
+            }
 
-            // update initial selection
-            _selectedSearchResult.update { cyclicIterator.current() }
-        }
+        _queryResults.update { queryResults }
     }
 
     /**
      * Iterate through searchResults in backward direction.
      *
-     * Results would be updated to [selectedSearchResult] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      *
      * Throws [NoSuchElementException] is search results are empty.
      */
-    suspend fun prev() {
-        if (searchResults.value.results.isEmpty())
+    public suspend fun producePreviousResult() {
+        val currentResult = queryResults.value
+
+        if (currentResult !is QueryResults.Matched)
             throw NoSuchElementException("Iteration not possible over empty results")
 
-        _selectedSearchResult.update { cyclicIterator.prev() }
+        /*
+         Create a shallow copy of the query result, updating only the `queryResultIndex`
+         to point to the previous element in the `resultsBounds` of the current query result.
+        */
+        val prevResult =
+            QueryResults.Matched(
+                query = currentResult.query,
+                resultBounds = currentResult.resultBounds,
+                pageRange = currentResult.pageRange,
+                queryResultsIndex = cyclicIterator.prev()
+            )
+
+        _queryResults.update { prevResult }
     }
 
     /**
      * Iterate through searchResults in forward direction.
      *
-     * Results would be updated to [selectedSearchResult] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      *
      * Throws [NoSuchElementException] is search results are empty.
      */
-    suspend fun next() {
-        if (searchResults.value.results.isEmpty())
+    public suspend fun produceNextResult() {
+        val currentResult = queryResults.value
+
+        if (currentResult !is QueryResults.Matched)
             throw NoSuchElementException("Iteration not possible over empty results")
 
-        _selectedSearchResult.update { cyclicIterator.next() }
+        /*
+         Create a shallow copy of the query result, updating only the `queryResultIndex`
+         to point to the next element in the `resultsBounds` of the current query result.
+        */
+        val nextResult =
+            QueryResults.Matched(
+                query = currentResult.query,
+                resultBounds = currentResult.resultBounds,
+                pageRange = currentResult.pageRange,
+                queryResultsIndex = cyclicIterator.next()
+            )
+
+        _queryResults.update { nextResult }
     }
 
     /**
-     * Resets [searchResults] and [selectedSearchResult] to initial state. This would be required to
-     * handle close/cancel action.
+     * Resets [queryResults] to initial state. This would be required to handle close/cancel action.
      */
-    fun clearSearchResults() {
-        _searchResults.update { SearchResults() }
-        _selectedSearchResult.update { null }
-    }
-
-    /**
-     * Set [searchResults] and [selectedSearchResult] flows to provided value.
-     *
-     * This should be utilized when result state is already available(muck like in restore scenario)
-     */
-    fun setState(
-        searchResults: SearchResults,
-        selectedSearchResult: SelectedSearchResult?,
-        currentVisiblePage: Int
-    ) {
-        _searchResults.update { searchResults }
-        _selectedSearchResult.update { selectedSearchResult }
-        // initiate iterator is results are not empty
-        if (searchResults.results.isNotEmpty())
-            cyclicIterator = CyclicSparseArrayIterator(searchResults.results, currentVisiblePage)
+    public fun clearSearchResults() {
+        _queryResults.update { NoQuery }
     }
 }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt
new file mode 100644
index 0000000..52c2f36
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 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 androidx.pdf.search.model
+
+import android.util.SparseArray
+import androidx.annotation.RestrictTo
+import androidx.pdf.content.PageMatchBounds
+
+/** A sealed interface that encapsulates the various states of a search operation's result. */
+@RestrictTo(RestrictTo.Scope.LIBRARY) public sealed interface SearchResultState
+
+/**
+ * Represents the initial state when no query has been submitted to trigger a search operation. This
+ * state occurs before any search is initiated.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY) public object NoQuery : SearchResultState
+
+/**
+ * A sealed class representing the outcome of a search operation.
+ *
+ * @param query The search query that initiated the search.
+ * @param pageRange The range of PDF pages involved in the search.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public sealed class QueryResults(public val query: String, public val pageRange: IntRange) :
+    SearchResultState {
+
+    /**
+     * Represents the state when no results are found after a search operation. This indicates that
+     * the search yielded no matching results.
+     *
+     * @param query The search query that was executed.
+     * @param pageRange The range of PDF pages included in the search.
+     */
+    public class NoMatch(query: String, pageRange: IntRange) : QueryResults(query, pageRange)
+
+    /**
+     * Represents the state when a search operation returns results.
+     *
+     * @param query The search query that was executed.
+     * @param pageRange The range of PDF pages included in the search.
+     * @param resultBounds A mapping of match bounds for the results, indexed by their position.
+     * @param queryResultsIndex Represents an index pointer to an element in [resultBounds].
+     */
+    public class Matched(
+        query: String,
+        pageRange: IntRange,
+        public val resultBounds: SparseArray<List<PageMatchBounds>>,
+        public val queryResultsIndex: QueryResultsIndex,
+    ) : QueryResults(query, pageRange)
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt
new file mode 100644
index 0000000..36aa484
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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 androidx.pdf.search.model
+
+import androidx.annotation.RestrictTo
+
+/** A model class that holds the index of a data element within [QueryResults]'s resultBounds. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class QueryResultsIndex(
+
+    /** The page number of the document where the current search result is located. */
+    public val pageNum: Int,
+
+    /** The index of the search result on the page specified by [pageNum]. */
+    public val resultBoundsIndex: Int
+)
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt
deleted file mode 100644
index 407cd13..0000000
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2024 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 androidx.pdf.search.model
-
-import android.util.SparseArray
-import androidx.annotation.RestrictTo
-import androidx.pdf.content.PageMatchBounds
-
-/** Model class to hold search results over pdf document for a search query. */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SearchResults(
-    /**
-     * search query provided to initiate search
-     *
-     * By default it will be empty string.
-     */
-    val searchQuery: String = "",
-    /**
-     * search results in pdf document for [searchQuery]
-     *
-     * By default it will be initialized to empty [SparseArray].
-     */
-    val results: SparseArray<List<PageMatchBounds>> = SparseArray()
-)
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt
deleted file mode 100644
index cb39cb7..0000000
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright 2024 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 androidx.pdf.search.model
-
-import androidx.annotation.RestrictTo
-
-/** Model class to hold current selected search result. */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SelectedSearchResult(
-
-    /** Represents document page number where current search result is selected */
-    val pageNum: Int,
-
-    /** index of result on the page specified by [pageNum] */
-    val currentIndex: Int
-)
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/AccessibilityPageHelper.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/AccessibilityPageHelper.kt
new file mode 100644
index 0000000..f3e20c0
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/AccessibilityPageHelper.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2024 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 androidx.pdf.view
+
+import android.content.Context
+import android.graphics.Rect
+import android.os.Bundle
+import androidx.annotation.NonNull
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
+import androidx.customview.widget.ExploreByTouchHelper
+import androidx.pdf.R
+
+/**
+ * Accessibility delegate for PdfView that provides a virtual view hierarchy for pages.
+ *
+ * This helper class allows accessibility services to interact with individual pages as virtual
+ * views, enabling navigation and content exploration.
+ */
+internal class AccessibilityPageHelper(
+    private val pdfView: PdfView,
+    private val pageLayoutManager: PageLayoutManager,
+    private val pageManager: PageManager
+) : ExploreByTouchHelper(pdfView) {
+
+    override fun getVirtualViewAt(x: Float, y: Float): Int {
+        val visiblePages = pageLayoutManager.visiblePages.value
+
+        val contentX = pdfView.toContentX(x).toInt()
+        val contentY = pdfView.toContentY(y).toInt()
+
+        return (visiblePages.lower..visiblePages.upper).firstOrNull { page ->
+            pageLayoutManager
+                .getPageLocation(page, pdfView.getVisibleAreaInContentCoords())
+                .contains(contentX, contentY)
+        } ?: HOST_ID
+    }
+
+    override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
+        val visiblePages = pageLayoutManager.visiblePages.value
+        virtualViewIds.addAll(visiblePages.lower..visiblePages.upper)
+    }
+
+    override fun onPopulateNodeForVirtualView(
+        virtualViewId: Int,
+        @NonNull node: AccessibilityNodeInfoCompat
+    ) {
+        node.apply {
+            // Set content description (use extracted text if available, otherwise a placeholder)
+            val pageText = pageManager.pages.get(virtualViewId)?.pageText
+            contentDescription =
+                pageText?.let { getContentDescriptionForPage(pdfView.context, virtualViewId, it) }
+                    ?: getDefaultDesc(pdfView.context, virtualViewId)
+
+            val pageBounds =
+                pageLayoutManager.getPageLocation(
+                    virtualViewId,
+                    pdfView.getVisibleAreaInContentCoords()
+                )
+            setBoundsInScreenFromBoundsInParent(node, scalePageBounds(pageBounds, pdfView.zoom))
+
+            isFocusable = true
+        }
+    }
+
+    override fun onPerformActionForVirtualView(
+        virtualViewId: Int,
+        action: Int,
+        arguments: Bundle?
+    ): Boolean {
+        // This view does not handle any actions.
+        return false
+    }
+
+    private fun scalePageBounds(bounds: Rect, zoom: Float): Rect {
+        return Rect(
+            (bounds.left * zoom).toInt(),
+            (bounds.top * zoom).toInt(),
+            (bounds.right * zoom).toInt(),
+            (bounds.bottom * zoom).toInt()
+        )
+    }
+
+    /**
+     * Updates accessibility node with extracted page text when ready.
+     *
+     * @param pageNum 0-indexed page number.
+     */
+    fun onPageTextReady(pageNum: Int) {
+        val pageText = pageManager.pages.get(pageNum)?.pageText
+
+        if (pageText != null) {
+            // Update accessibility node with new text.
+            invalidateVirtualView(pageNum)
+        }
+    }
+
+    companion object {
+
+        /**
+         * Builds the content description for a page.
+         *
+         * @param context The context for accessing resources.
+         * @param pageText The extracted text content of the page, or null if not loaded.
+         * @param pageNum The 0-indexed page number.
+         * @return The content description string.
+         */
+        private fun getContentDescriptionForPage(
+            context: Context,
+            pageNum: Int,
+            pageText: String?
+        ): String {
+            return when {
+                pageText == null -> getDefaultDesc(context, pageNum)
+                pageText.trim().isEmpty() -> context.getString(R.string.desc_empty_page)
+                else -> context.getString(R.string.desc_page_with_text, pageNum + 1, pageText)
+            }
+        }
+
+        /**
+         * Gets the default content description for a page. This is used as a placeholder while the
+         * actual content description is loading.
+         *
+         * @param context The context for accessing resources.
+         * @param pageNum The 0-indexed page number.
+         * @return The default content description string.
+         */
+        private fun getDefaultDesc(context: Context, pageNum: Int): String {
+            return context.getString(R.string.desc_page, pageNum + 1)
+        }
+    }
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/BitmapFetcher.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/BitmapFetcher.kt
new file mode 100644
index 0000000..8b223f0
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/BitmapFetcher.kt
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024 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 androidx.pdf.view
+
+import android.graphics.Bitmap
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.Rect
+import android.util.Size
+import androidx.annotation.MainThread
+import androidx.annotation.VisibleForTesting
+import androidx.pdf.PdfDocument
+import kotlin.math.roundToInt
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+
+/**
+ * Manages the loading of [Bitmap]s for a single PDF page. Keeps track of the current zoom level and
+ * will switch between full page rendering to tiled rendering as dictated by [maxBitmapSizePx]
+ */
+internal class BitmapFetcher(
+    private val pageNum: Int,
+    private val pageSize: Point,
+    private val pdfDocument: PdfDocument,
+    private val backgroundScope: CoroutineScope,
+    /**
+     * The maximum size of a single bitmap in pixels. If the pageSize * current zoom exceeds this
+     * threshold, we will start to use tiled rendering.
+     */
+    private val maxBitmapSizePx: Point,
+    private val onPageUpdate: () -> Unit,
+) {
+
+    /**
+     * The maximum size of a full page bitmap that is used as the background for a tiled rendering.
+     * We draw a low-res bitmap behind tiles to avoid blank spaces in the page as high res tiles are
+     * being loaded.
+     */
+    private val maxTileBackgroundSizePx = Point(maxBitmapSizePx.x / 2, maxBitmapSizePx.y / 2)
+
+    var isActive: Boolean = false
+        set(value) {
+            // Debounce setting the field to the same value
+            if (field == value) return
+            field = value
+            if (field) onActive() else onInactive()
+        }
+
+    @get:MainThread var pageContents: PageContents? = null
+
+    private var bitmapSource: PdfDocument.BitmapSource? = null
+    @VisibleForTesting var currentRenderingScale: Float? = null
+    @VisibleForTesting var renderingJob: Job? = null
+
+    /**
+     * Notify this fetcher that the zoom level / scale factor of the UI has changed, and that it
+     * ought to consider fetching new bitmaps
+     */
+    fun onScaleChanged(scale: Float) {
+        if (!shouldRenderNewBitmaps(scale)) return
+
+        currentRenderingScale = scale
+        renderingJob?.cancel()
+        renderingJob =
+            if (needsTiling(scale)) {
+                fetchTiles(scale)
+            } else {
+                fetchNewBitmap(scale)
+            }
+        renderingJob?.invokeOnCompletion { cause ->
+            // We only want to reset these states when we completed naturally
+            if (cause is CancellationException) return@invokeOnCompletion
+            renderingJob = null
+            currentRenderingScale = null
+        }
+    }
+
+    private fun shouldRenderNewBitmaps(scale: Float): Boolean {
+        val renderingAtCurrentScale =
+            currentRenderingScale == scale && renderingJob?.isActive == true
+        val renderedAtCurrentScale = pageContents?.let { it.renderedScale == scale } ?: false
+
+        return !renderedAtCurrentScale && !renderingAtCurrentScale
+    }
+
+    /** Prepare to start fetching bitmaps */
+    private fun onActive() {
+        bitmapSource = pdfDocument.getPageBitmapSource(pageNum)
+    }
+
+    /**
+     * Cancel ongoing work and release resources, including [Bitmap]s and [AutoCloseable]s held by
+     * this fetcher
+     */
+    private fun onInactive() {
+        currentRenderingScale = null
+        pageContents = null
+        renderingJob?.cancel()
+        renderingJob = null
+        bitmapSource?.close()
+        bitmapSource = null
+    }
+
+    /** Fetch a [FullPageBitmap] */
+    private fun fetchNewBitmap(scale: Float): Job {
+        return backgroundScope.launch {
+            val size = limitBitmapSize(scale, maxBitmapSizePx)
+            // If our BitmapSource is null that means this fetcher is inactive and we should
+            // stop what we're doing
+            val bitmap = bitmapSource?.getBitmap(size) ?: return@launch
+            ensureActive()
+            pageContents = FullPageBitmap(bitmap, scale)
+            onPageUpdate()
+        }
+    }
+
+    /** Fetch a [TileBoard] */
+    private fun fetchTiles(scale: Float): Job {
+        val pageSizePx = Point((pageSize.x * scale).roundToInt(), (pageSize.y * scale).roundToInt())
+        val tileBoard = TileBoard(tileSizePx, pageSizePx, scale)
+        // Re-use an existing background bitmap if we have one to avoid unnecessary re-rendering
+        // and jank
+        val prevBackground = (tileBoard as? TileBoard)?.backgroundBitmap
+        if (prevBackground != null) {
+            tileBoard.backgroundBitmap = prevBackground
+            pageContents = tileBoard
+            onPageUpdate()
+        }
+        return backgroundScope.launch {
+            // Render a new background bitmap if we must
+            if (prevBackground == null) {
+                // If our BitmapSource is null that means this fetcher is inactive and we should
+                // stop what we're doing
+                val backgroundSize = limitBitmapSize(scale, maxTileBackgroundSizePx)
+                val bitmap = bitmapSource?.getBitmap(backgroundSize) ?: return@launch
+                pageContents = tileBoard
+                ensureActive()
+                tileBoard.backgroundBitmap = bitmap
+                onPageUpdate()
+            }
+            for (tile in tileBoard.tiles) {
+                renderBitmap(tile, coroutineContext.job, scale)
+            }
+        }
+    }
+
+    /** Render a [Bitmap] for this [TileBoard.Tile] */
+    private suspend fun renderBitmap(tile: TileBoard.Tile, thisJob: Job, scale: Float) {
+        thisJob.ensureActive()
+        val left = tile.offsetPx.x
+        val top = tile.offsetPx.y
+        val tileRect = Rect(left, top, left + tile.exactSizePx.x, top + tile.exactSizePx.y)
+        // If our BitmapSource is null that means this fetcher is inactive and we should
+        // stop what we're doing
+        val bitmap =
+            bitmapSource?.getBitmap(
+                Size((pageSize.x * scale).roundToInt(), (pageSize.y * scale).roundToInt()),
+                tileRect
+            ) ?: return
+        thisJob.ensureActive()
+        tile.bitmap = bitmap
+        onPageUpdate()
+    }
+
+    /** True if the [pageSize] * [scale] exceeds [maxBitmapSizePx] */
+    private fun needsTiling(scale: Float): Boolean {
+        return ((pageSize.x * scale) >= maxBitmapSizePx.x) ||
+            ((pageSize.y * scale) >= maxBitmapSizePx.y)
+    }
+
+    /**
+     * Returns a size that is as near as possible to [pageSize] * [requestedScale] while being
+     * smaller than [maxSize] in both dimensions
+     */
+    private fun limitBitmapSize(requestedScale: Float, maxSize: Point): Size {
+        val finalSize = PointF(pageSize.x * requestedScale, pageSize.y * requestedScale)
+        // Reduce final size by 10% in each dimension until the constraints are satisfied
+        while (finalSize.x > maxSize.x || finalSize.y > maxSize.y) {
+            finalSize.x *= 0.9f
+            finalSize.y *= 0.9f
+        }
+        return Size(finalSize.x.roundToInt(), finalSize.y.roundToInt())
+    }
+
+    companion object {
+        /** The size of a single tile in pixels, when tiling is used */
+        @VisibleForTesting internal val tileSizePx = Point(800, 800)
+    }
+}
+
+/** Represents the [Bitmap] or [Bitmap]s used to render this page */
+internal sealed interface PageContents {
+    val renderedScale: Float
+}
+
+/** A singular [Bitmap] depicting the full page, when full page rendering is used */
+internal class FullPageBitmap(val bitmap: Bitmap, override val renderedScale: Float) : PageContents
+
+/**
+ * A set of [Bitmap]s that depict the full page as a rectangular grid of individual bitmap tiles.
+ * This [PageContents] is mutable; it's updated with new Bitmaps as tiles are loaded incrementally
+ */
+internal class TileBoard(
+    val tileSizePx: Point,
+    val pageSizePx: Point,
+    override val renderedScale: Float
+) : PageContents {
+
+    /** The low res background [Bitmap] for this [TileBoard] */
+    var backgroundBitmap: Bitmap? = null
+
+    /** The number of rows in the current tiling */
+    private val numRows
+        get() = (1 + (pageSizePx.y - 1) / tileSizePx.y)
+
+    /** The number of columns in the current tiling */
+    private val numCols
+        get() = (1 + (pageSizePx.x - 1) / tileSizePx.x)
+
+    /** The [Tile]s in this board */
+    val tiles = Array(numRows * numCols) { index -> Tile(index) }
+
+    /** An individual [Tile] in this [TileBoard] */
+    inner class Tile(index: Int) {
+        /** The x position of this tile in the tile board */
+        private val rowIdx = index / numCols
+
+        /** The y position of this tile in the tile board */
+        private val colIdx = index % numCols
+
+        /**
+         * The offset of this [Tile] from the origin of the page in pixels, used in computations
+         * where an exact pixel size is expected, e.g. rendering bitmaps
+         */
+        val offsetPx = Point(colIdx * tileSizePx.x, rowIdx * tileSizePx.y)
+
+        /** The size of this [Tile] in pixels */
+        val exactSizePx =
+            Point(
+                minOf(tileSizePx.x, pageSizePx.x - offsetPx.x),
+                minOf(tileSizePx.y, pageSizePx.y - offsetPx.y),
+            )
+
+        /** The high res [Bitmap] for this [Tile] */
+        var bitmap: Bitmap? = null
+    }
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt
index dbc764e..cdf2a80 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt
@@ -16,20 +16,18 @@
 
 package androidx.pdf.view
 
-import android.graphics.Bitmap
 import android.graphics.Canvas
 import android.graphics.Color
 import android.graphics.Paint
+import android.graphics.Paint.Style
 import android.graphics.Point
 import android.graphics.Rect
 import android.graphics.RectF
-import android.util.Size
 import androidx.annotation.RestrictTo
 import androidx.annotation.VisibleForTesting
 import androidx.pdf.PdfDocument
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.launch
 
 /** A single PDF page that knows how to render and draw itself */
@@ -38,54 +36,86 @@
     /** The 0-based index of this page in the PDF */
     private val pageNum: Int,
     /** The size of this PDF page, in content coordinates */
-    private val size: Point,
+    pageSizePx: Point,
     /** The [PdfDocument] this [Page] belongs to */
     private val pdfDocument: PdfDocument,
     /** The [CoroutineScope] to use for background work */
     private val backgroundScope: CoroutineScope,
+    /**
+     * The maximum size of any single [android.graphics.Bitmap] we render for a page, i.e. the
+     * threshold for tiled rendering
+     */
+    maxBitmapSizePx: Point,
     /** A function to call when the [PdfView] hosting this [Page] ought to invalidate itself */
-    private val onPageUpdate: () -> Unit,
+    onPageUpdate: () -> Unit,
+    /** A function to call when page text is ready (invoked with page number). */
+    private val onPageTextReady: ((Int) -> Unit)
 ) {
     init {
         require(pageNum >= 0) { "Invalid negative page" }
     }
 
-    /**
-     * Pre-allocated [Paint] to draw [Highlight]s, color is changed at drawing time to the value
-     * defined by the [Highlight]
-     */
-    private val highlightPaint = Paint().apply { style = Paint.Style.FILL }
+    /** Handles rendering bitmaps for this page using [PdfDocument] */
+    private val bitmapFetcher =
+        BitmapFetcher(
+            pageNum,
+            pageSizePx,
+            pdfDocument,
+            backgroundScope,
+            maxBitmapSizePx,
+            onPageUpdate,
+        )
 
-    /**
-     * Pre-allocated [RectF] used to represent the View-coordinate location of a [Highlight] during
-     * drawing
-     */
+    // Pre-allocated values to avoid allocations at drawing time
+    private val highlightPaint = Paint().apply { style = Style.FILL }
     private val highlightRect = RectF()
-
-    private var isVisible: Boolean = false
-    private var renderedZoom: Float? = null
-    @VisibleForTesting internal var renderBitmapJob: Job? = null
-    @VisibleForTesting internal var bitmap: Bitmap? = null
+    private val tileLocationRect = RectF()
+    internal var fetchPageTextJob: Job? = null
+    internal var pageText: String? = null
+    internal var links: PdfDocument.PdfPageLinks? = null
+        private set
 
     fun setVisible(zoom: Float) {
-        isVisible = true
-        maybeUpdateBitmaps(zoom)
+        bitmapFetcher.isActive = true
+        bitmapFetcher.onScaleChanged(zoom)
+        if (links == null) {
+            fetchLinks()
+        }
+        fetchPageText()
     }
 
     fun setInvisible() {
-        isVisible = false
-        renderBitmapJob?.cancel()
-        renderBitmapJob = null
-        bitmap = null
-        renderedZoom = null
+        bitmapFetcher.isActive = false
+
+        pageText = null
+        fetchPageTextJob?.cancel()
+        fetchPageTextJob = null
+    }
+
+    fun fetchPageText() {
+        if (fetchPageTextJob?.isActive == true || pageText != null) {
+            return
+        }
+
+        fetchPageTextJob =
+            backgroundScope.launch {
+                pageText =
+                    pdfDocument.getPageContent(pageNum)?.textContents?.joinToString { it.text }
+                onPageTextReady.invoke(pageNum)
+            }
     }
 
     fun draw(canvas: Canvas, locationInView: Rect, highlights: List<Highlight>) {
-        if (bitmap == null) {
+        val pageBitmaps = bitmapFetcher.pageContents
+        if (pageBitmaps == null) {
             canvas.drawRect(locationInView, BLANK_PAINT)
             return
         }
-        bitmap?.let { canvas.drawBitmap(it, /* src= */ null, locationInView, BMP_PAINT) }
+        if (pageBitmaps is FullPageBitmap) {
+            draw(pageBitmaps, canvas, locationInView)
+        } else if (pageBitmaps is TileBoard) {
+            draw(pageBitmaps, canvas, locationInView)
+        }
         for (highlight in highlights) {
             // Highlight locations are defined in content coordinates, compute their location
             // in View coordinates using locationInView
@@ -96,39 +126,58 @@
         }
     }
 
-    private fun maybeUpdateBitmaps(zoom: Float) {
-        // If we're actively rendering or have rendered a bitmap for the current zoom level, there's
-        // no need to refresh bitmaps
-        if (renderedZoom?.equals(zoom) == true && (bitmap != null || renderBitmapJob != null)) {
-            return
-        }
-        renderBitmapJob?.cancel()
-        // If we're not visible, don't bother fetching new bitmaps
-        if (!isVisible) return
-        fetchNewBitmap(zoom)
+    private fun fetchLinks() {
+        backgroundScope.launch { links = pdfDocument.getPageLinks(pageNum) }
     }
 
-    private fun fetchNewBitmap(zoom: Float) {
-        val bitmapSource = pdfDocument.getPageBitmapSource(pageNum)
-        renderBitmapJob =
-            backgroundScope.launch {
-                ensureActive()
-                val width = (size.x * zoom).toInt()
-                val height = (size.y * zoom).toInt()
-                renderedZoom = zoom
-                bitmap = bitmapSource.getBitmap(Size(width, height))
-                ensureActive()
-                onPageUpdate.invoke()
+    private fun draw(fullPageBitmap: FullPageBitmap, canvas: Canvas, locationInView: Rect) {
+        canvas.drawBitmap(fullPageBitmap.bitmap, /* src= */ null, locationInView, BMP_PAINT)
+    }
+
+    private fun draw(tileBoard: TileBoard, canvas: Canvas, locationInView: Rect) {
+        tileBoard.backgroundBitmap?.let {
+            canvas.drawBitmap(it, /* src= */ null, locationInView, BMP_PAINT)
+        }
+        for (tile in tileBoard.tiles) {
+            tile.bitmap?.let { bitmap ->
+                canvas.drawBitmap(
+                    bitmap, /* src */
+                    null,
+                    locationForTile(tile, tileBoard.renderedScale, locationInView),
+                    BMP_PAINT
+                )
             }
-        renderBitmapJob?.invokeOnCompletion { renderBitmapJob = null }
+        }
+    }
+
+    private fun locationForTile(
+        tile: TileBoard.Tile,
+        renderedScale: Float,
+        locationInView: Rect
+    ): RectF {
+        val tileOffsetPx = tile.offsetPx
+        // The tile describes its own location in pixels, i.e. scaled coordinates, however
+        // our Canvas is already scaled by the zoom factor, so we need to describe the tile's
+        // location to the Canvas in unscaled coordinates
+        val left = locationInView.left + tileOffsetPx.x / renderedScale
+        val top = locationInView.top + tileOffsetPx.y / renderedScale
+        val exactSize = tile.exactSizePx
+        tileLocationRect.set(
+            left,
+            top,
+            left + exactSize.x / renderedScale,
+            top + exactSize.y / renderedScale
+        )
+        return tileLocationRect
     }
 }
 
 /** Constant [Paint]s used in drawing */
 @VisibleForTesting internal val BMP_PAINT = Paint(Paint.FILTER_BITMAP_FLAG)
+
 @VisibleForTesting
 internal val BLANK_PAINT =
     Paint().apply {
         color = Color.WHITE
-        style = Paint.Style.FILL
+        style = Style.FILL
     }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
index 59fe5c2..4af7810 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
@@ -17,7 +17,9 @@
 package androidx.pdf.view
 
 import android.graphics.Point
+import android.graphics.PointF
 import android.graphics.Rect
+import android.graphics.RectF
 import android.util.Range
 import androidx.pdf.PdfDocument
 import kotlin.math.ceil
@@ -102,6 +104,47 @@
     }
 
     /**
+     * Returns the page number containing the specified PDF coordinates within the given viewport.
+     * If no page contains the coordinates, returns [INVALID_ID].
+     *
+     * @param pdfCoordinates The PDF coordinates to check.
+     * @param viewport The visible area of the PDF.
+     * @return The page number or [INVALID_ID].
+     */
+    fun getPageNumberAt(pdfCoordinates: PointF, viewport: Rect): Int {
+        val visiblePages = visiblePages.value
+        for (pageIndex in visiblePages.lower..visiblePages.upper) {
+            val pageBounds = paginationModel.getPageLocation(pageIndex, viewport)
+            if (RectF(pageBounds).contains(pdfCoordinates.x, pdfCoordinates.y)) {
+                return pageIndex
+            }
+        }
+        return INVALID_ID
+    }
+
+    /**
+     * Converts tap coordinates (relative to the viewport) to content coordinates relative to the
+     * specified page.
+     *
+     * @param pageNum The 0-indexed page number.
+     * @param viewport The current viewport's dimensions.
+     * @param tapCoordinates The tap coordinates relative to the visible pages.
+     * @return The coordinates relative to the clicked page.
+     */
+    fun getPageCoordinatesRelativeToTappedPage(
+        pageNum: Int,
+        viewport: Rect,
+        tapCoordinates: PointF
+    ): PointF {
+        val pageLocation = getPageLocation(pageNum, viewport)
+
+        val contentX = tapCoordinates.x - pageLocation.left
+        val contentY = tapCoordinates.y - pageLocation.top
+
+        return PointF(contentX, contentY)
+    }
+
+    /**
      * Emits a new [Range] to [visiblePages] based on the current [scrollY], [height], and [zoom] of
      * a [PdfView]
      */
@@ -148,5 +191,6 @@
 
     companion object {
         private const val DEFAULT_PAGE_SPACING_PX: Int = 20
+        private const val INVALID_ID = -1
     }
 }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageManager.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageManager.kt
index d1d0e00..8c4c23b 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageManager.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageManager.kt
@@ -21,7 +21,6 @@
 import android.graphics.Rect
 import android.util.Range
 import android.util.SparseArray
-import androidx.annotation.VisibleForTesting
 import androidx.core.util.keyIterator
 import androidx.core.util.valueIterator
 import androidx.pdf.PdfDocument
@@ -41,6 +40,11 @@
     private val pdfDocument: PdfDocument,
     private val backgroundScope: CoroutineScope,
     private val pagePrefetchRadius: Int,
+    /**
+     * The maximum size of any single [android.graphics.Bitmap] we render for a page, i.e. the
+     * threshold for tiled rendering
+     */
+    private val maxBitmapSizePx: Point,
 ) {
     /**
      * Replay at least 1 value in case of an invalidation signal issued while [PdfView] is not
@@ -58,7 +62,11 @@
     val invalidationSignalFlow: SharedFlow<Unit>
         get() = _invalidationSignalFlow
 
-    @VisibleForTesting val pages = SparseArray<Page>()
+    internal val pages = SparseArray<Page>()
+
+    private val _pageTextReadyFlow = MutableSharedFlow<Int>(replay = 1)
+    val pageTextReadyFlow: SharedFlow<Int>
+        get() = _pageTextReadyFlow
 
     /**
      * [Highlight]s supplied by the developer to be drawn along with the pages they belong to
@@ -99,9 +107,15 @@
     fun onPageSizeReceived(pageNum: Int, size: Point, isVisible: Boolean, currentZoomLevel: Float) {
         if (pages.contains(pageNum)) return
         val page =
-            Page(pageNum, size, pdfDocument, backgroundScope) {
-                    _invalidationSignalFlow.tryEmit(Unit)
-                }
+            Page(
+                    pageNum,
+                    size,
+                    pdfDocument,
+                    backgroundScope,
+                    maxBitmapSizePx,
+                    onPageUpdate = { _invalidationSignalFlow.tryEmit(Unit) },
+                    onPageTextReady = { pageNumber -> _pageTextReadyFlow.tryEmit(pageNumber) }
+                )
                 .apply { if (isVisible) setVisible(currentZoomLevel) }
         pages.put(pageNum, page)
     }
@@ -130,6 +144,10 @@
             page.setInvisible()
         }
     }
+
+    fun getLinkAtTapPoint(pdfPoint: PdfPoint): PdfDocument.PdfPageLinks? {
+        return pages[pdfPoint.pageNum]?.links
+    }
 }
 
 /** Constant empty list to avoid allocations during drawing */
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
index 3c9b951..c104fd2 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
@@ -17,22 +17,31 @@
 package androidx.pdf.view
 
 import android.content.Context
+import android.content.Intent
 import android.graphics.Canvas
 import android.graphics.Point
 import android.graphics.PointF
 import android.graphics.Rect
+import android.net.Uri
 import android.os.Looper
 import android.os.Parcelable
 import android.util.AttributeSet
 import android.util.Range
+import android.view.KeyEvent
 import android.view.MotionEvent
+import android.view.ScaleGestureDetector
 import android.view.View
 import android.widget.OverScroller
 import androidx.annotation.RestrictTo
 import androidx.core.os.HandlerCompat
+import androidx.core.view.ViewCompat
 import androidx.pdf.PdfDocument
 import androidx.pdf.util.ZoomUtils
+import java.util.LinkedList
+import java.util.Queue
 import java.util.concurrent.Executors
+import kotlin.math.abs
+import kotlin.math.max
 import kotlin.math.round
 import kotlin.math.roundToInt
 import kotlinx.coroutines.CoroutineScope
@@ -87,6 +96,7 @@
             checkMainThread()
             field = value
             onZoomChanged()
+            invalidate()
         }
 
     /**
@@ -114,6 +124,19 @@
     public val visiblePagesCount: Int
         get() = if (pdfDocument != null) visiblePages.upper - visiblePages.lower + 1 else 0
 
+    /** Listener interface for handling clicks on links in a PDF document. */
+    public interface LinkClickListener {
+        /**
+         * Called when a link in the PDF is clicked.
+         *
+         * @param uri The URI associated with the link.
+         */
+        public fun onLinkClicked(uri: Uri)
+    }
+
+    /** The listener that is notified when a link in the PDF is clicked. */
+    public var linkClickListener: LinkClickListener? = null
+
     /**
      * The [CoroutineScope] used to make suspending calls to [PdfDocument]. The size of the fixed
      * thread pool is arbitrary and subject to tuning.
@@ -136,13 +159,15 @@
     private var scrollPositionToRestore: PointF? = null
     private var zoomToRestore: Float? = null
 
-    private val gestureHandler = ZoomScrollGestureHandler(this@PdfView)
-    private val gestureTracker = GestureTracker(context).apply { delegate = gestureHandler }
+    private val gestureTracker =
+        GestureTracker(context).apply { delegate = ZoomScrollGestureHandler() }
     private val scroller = OverScroller(context)
 
     // To avoid allocations during drawing
     private val visibleAreaRect = Rect()
 
+    private var accessibilityPageHelper: AccessibilityPageHelper? = null
+
     /**
      * Scrolls to the 0-indexed [pageNum], optionally animating the scroll
      *
@@ -251,6 +276,21 @@
         scrollTo(x.toInt(), y.toInt())
     }
 
+    override fun dispatchHoverEvent(event: MotionEvent): Boolean {
+        return accessibilityPageHelper?.dispatchHoverEvent(event) == true ||
+            super.dispatchHoverEvent(event)
+    }
+
+    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+        return accessibilityPageHelper?.dispatchKeyEvent(event) == true ||
+            super.dispatchKeyEvent(event)
+    }
+
+    override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
+        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
+        accessibilityPageHelper?.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
+    }
+
     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
         val localPaginationManager = pageLayoutManager ?: return
@@ -274,11 +314,13 @@
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         pageLayoutManager?.onViewportChanged(scrollY, height, zoom)
+        accessibilityPageHelper?.invalidateRoot()
     }
 
     override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
         super.onScrollChanged(l, t, oldl, oldt)
         pageLayoutManager?.onViewportChanged(scrollY, height, zoom)
+        accessibilityPageHelper?.invalidateRoot()
     }
 
     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -407,6 +449,7 @@
         } else {
             scrollToRestoredPosition(positionToRestore, localStateToRestore.zoom)
         }
+        setAccessibility()
 
         stateToRestore = null
         return true
@@ -450,7 +493,12 @@
                 mainScope.launch(start = CoroutineStart.UNDISPATCHED) {
                     // Prevent 2 copies from running concurrently
                     invalidationToJoin?.join()
-                    manager.invalidationSignalFlow.collect { invalidate() }
+                    launch { manager.invalidationSignalFlow.collect { invalidate() } }
+                    launch {
+                        manager.pageTextReadyFlow.collect { pageNum ->
+                            accessibilityPageHelper?.onPageTextReady(pageNum)
+                        }
+                    }
                 }
         }
     }
@@ -462,14 +510,29 @@
     }
 
     /** Start using the [PdfDocument] to present PDF content */
+    // Display.width and height are deprecated in favor of WindowMetrics, but in this case we
+    // actually want to use the size of the display and not the size of the window.
+    @Suppress("deprecation")
     private fun onDocumentSet() {
         val localPdfDocument = pdfDocument ?: return
-        pageManager = PageManager(localPdfDocument, backgroundScope, DEFAULT_PAGE_PREFETCH_RADIUS)
+        /* We use the maximum pixel dimension of the display as the maximum pixel dimension for any
+        single Bitmap we render, i.e. the threshold for tiled rendering. This is an arbitrary,
+        but reasonable threshold to use that does not depend on volatile state like the current
+        screen orientation or the current size of our application's Window. */
+        val maxBitmapDimensionPx = max(context.display.width, context.display.height)
+        pageManager =
+            PageManager(
+                localPdfDocument,
+                backgroundScope,
+                DEFAULT_PAGE_PREFETCH_RADIUS,
+                Point(maxBitmapDimensionPx, maxBitmapDimensionPx)
+            )
         // We'll either create our layout manager from restored state, or instantiate a new one
         if (!maybeRestoreState()) {
             pageLayoutManager =
                 PageLayoutManager(localPdfDocument, backgroundScope, DEFAULT_PAGE_PREFETCH_RADIUS)
                     .apply { onViewportChanged(scrollY, height, zoom) }
+            setAccessibility()
         }
         // If not, we'll start doing this when we _are_ attached to a visible window
         if (isAttachedToVisibleWindow) {
@@ -484,11 +547,22 @@
      * Compute what content is visible from the current position of this View. Generally invoked on
      * position or size changes.
      */
-    internal fun onZoomChanged() {
+    private fun onZoomChanged() {
         pageLayoutManager?.onViewportChanged(scrollY, height, zoom)
-        if (!gestureHandler.scaleInProgress && !gestureHandler.scrollInProgress) {
+        // Don't fetch new Bitmaps while the user is actively zooming, to avoid jank and rendering
+        // churn
+        if (!gestureTracker.matches(GestureTracker.Gesture.ZOOM)) {
             pageManager?.maybeUpdateBitmaps(visiblePages, zoom)
         }
+        accessibilityPageHelper?.invalidateRoot()
+    }
+
+    /**
+     * Invoked by gesture handlers to let this view know that its position has stabilized, i.e. it's
+     * not actively changing due to user input
+     */
+    internal fun onStableZoom() {
+        pageManager?.maybeUpdateBitmaps(visiblePages, zoom)
     }
 
     private fun reset() {
@@ -559,7 +633,7 @@
      * Computes the part of the content visible within the outer part of this view (including this
      * view's padding) in co-ordinates of the content.
      */
-    private fun getVisibleAreaInContentCoords(): Rect {
+    internal fun getVisibleAreaInContentCoords(): Rect {
         visibleAreaRect.set(
             toContentX(-paddingLeft.toFloat()).toInt(),
             toContentY(-paddingTop.toFloat()).toInt(),
@@ -569,6 +643,21 @@
         return visibleAreaRect
     }
 
+    /**
+     * Initializes and sets the accessibility delegate for the PdfView.
+     *
+     * This method creates an instance of [AccessibilityPageHelper] if both [.pageLayoutManager] and
+     * [.pageManager] are initialized, and sets it as the accessibility delegate for the view using
+     * [ViewCompat.setAccessibilityDelegate].
+     */
+    private fun setAccessibility() {
+        if (pageLayoutManager != null && pageManager != null) {
+            accessibilityPageHelper =
+                AccessibilityPageHelper(this, pageLayoutManager!!, pageManager!!)
+            ViewCompat.setAccessibilityDelegate(this, accessibilityPageHelper)
+        }
+    }
+
     /** The height of the viewport, minus padding */
     private val viewportHeight: Int
         get() = bottom - top - paddingBottom - paddingTop
@@ -578,12 +667,12 @@
         get() = right - left - paddingRight - paddingLeft
 
     /** Converts an X coordinate in View space to an X coordinate in content space */
-    private fun toContentX(viewX: Float): Float {
+    internal fun toContentX(viewX: Float): Float {
         return toContentCoord(viewX, zoom, scrollX)
     }
 
     /** Converts a Y coordinate in View space to a Y coordinate in content space */
-    private fun toContentY(viewY: Float): Float {
+    internal fun toContentY(viewY: Float): Float {
         return toContentCoord(viewY, zoom, scrollY)
     }
 
@@ -605,12 +694,218 @@
     private val contentHeight: Int
         get() = pageLayoutManager?.paginationModel?.totalEstimatedHeight ?: 0
 
+    internal inner class ZoomScrollGestureHandler : GestureTracker.GestureHandler() {
+        internal var scrollInProgress = false
+        internal var scaleInProgress = false
+
+        /**
+         * The multiplier to convert from a scale gesture's delta span, in pixels, to scale factor.
+         *
+         * [ScaleGestureDetector] returns scale factors proportional to the ratio of `currentSpan /
+         * prevSpan`. This is problematic because it results in scale factors that are very large
+         * for small pixel spans, which is particularly problematic for quickScale gestures, where
+         * the span pixel values can be small, but the ratio can yield very large scale factors.
+         *
+         * Instead, we use this to ensure that pinching or quick scale dragging a certain number of
+         * pixels always corresponds to a certain change in zoom. The equation that we've found to
+         * work well is a delta span of the larger screen dimension should result in a zoom change
+         * of 2x.
+         */
+        private val linearScaleSpanMultiplier: Float =
+            2f / maxOf(resources.displayMetrics.heightPixels, resources.displayMetrics.widthPixels)
+
+        /** The maximum scroll distance used to determine if the direction is vertical. */
+        private val maxScrollWindow =
+            (resources.displayMetrics.density * MAX_SCROLL_WINDOW_DP).toInt()
+
+        /** The smallest scroll distance that can switch mode to "free scrolling". */
+        private val minScrollToSwitch =
+            (resources.displayMetrics.density * MIN_SCROLL_TO_SWITCH_DP).toInt()
+
+        /** Remember recent scroll events so we can examine the general direction. */
+        private val scrollQueue: Queue<PointF> = LinkedList()
+
+        /** Are we correcting vertical scroll for the current gesture? */
+        private var straightenCurrentVerticalScroll = true
+
+        private var totalX = 0f
+        private var totalY = 0f
+
+        private val totalScrollLength
+            // No need for accuracy of correct hypotenuse calculation
+            get() = abs(totalX) + abs(totalY)
+
+        override fun onScroll(
+            e1: MotionEvent?,
+            e2: MotionEvent,
+            distanceX: Float,
+            distanceY: Float,
+        ): Boolean {
+            scrollInProgress = true
+            var dx = Math.round(distanceX)
+            val dy = Math.round(distanceY)
+
+            if (straightenCurrentVerticalScroll) {
+                // Remember a window of recent scroll events.
+                scrollQueue.offer(PointF(distanceX, distanceY))
+                totalX += distanceX
+                totalY += distanceY
+
+                // Only consider scroll direction for a certain window of scroll events.
+                while (totalScrollLength > maxScrollWindow && scrollQueue.size > 1) {
+                    // Remove the oldest scroll event - it is too far away to determine scroll
+                    // direction.
+                    val oldest = scrollQueue.poll()
+                    oldest?.let {
+                        totalY -= oldest.y
+                        totalX -= oldest.x
+                    }
+                }
+
+                if (
+                    totalScrollLength > minScrollToSwitch &&
+                        abs((totalY / totalX).toDouble()) < SCROLL_CORRECTION_RATIO
+                ) {
+                    straightenCurrentVerticalScroll = false
+                } else {
+                    // Ignore the horizontal component of the scroll.
+                    dx = 0
+                }
+            }
+
+            scrollBy(dx, dy)
+            return true
+        }
+
+        override fun onFling(
+            e1: MotionEvent?,
+            e2: MotionEvent,
+            velocityX: Float,
+            velocityY: Float
+        ): Boolean {
+            return super.onFling(e1, e2, velocityX, velocityY)
+            // TODO(b/376136621) Animate scroll position during a fling
+        }
+
+        override fun onDoubleTap(e: MotionEvent): Boolean {
+            return super.onDoubleTap(e)
+            // TODO(b/376136331) Toggle between fit-to-page and zoomed-in on double tap gestures
+        }
+
+        override fun onScale(detector: ScaleGestureDetector): Boolean {
+            scaleInProgress = true
+            val rawScaleFactor = detector.scaleFactor
+            val deltaSpan = abs(detector.currentSpan - detector.previousSpan)
+            val scaleDelta = deltaSpan * linearScaleSpanMultiplier
+            val linearScaleFactor = if (rawScaleFactor >= 1f) 1f + scaleDelta else 1f - scaleDelta
+            val newZoom = (zoom * linearScaleFactor).coerceIn(minZoom, maxZoom)
+
+            zoomTo(newZoom, detector.focusX, detector.focusY)
+            return true
+        }
+
+        override fun onGestureEnd(gesture: GestureTracker.Gesture?) {
+            when (gesture) {
+                GestureTracker.Gesture.ZOOM -> {
+                    scaleInProgress = false
+                    onZoomChanged()
+                }
+                GestureTracker.Gesture.DRAG,
+                GestureTracker.Gesture.DRAG_Y,
+                GestureTracker.Gesture.DRAG_X -> {
+                    scrollInProgress = false
+                    onZoomChanged()
+                }
+                else -> {
+                    /* no-op */
+                }
+            }
+            totalX = 0f
+            totalY = 0f
+            straightenCurrentVerticalScroll = true
+            scrollQueue.clear()
+        }
+
+        override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+            val contentX = toContentX(e.x)
+            val contentY = toContentY(e.y)
+
+            val pageLayoutManager = pageLayoutManager ?: return super.onSingleTapConfirmed(e)
+            val pageNum =
+                pageLayoutManager.getPageNumberAt(
+                    PointF(contentX, contentY),
+                    getVisibleAreaInContentCoords()
+                )
+            if (pageNum == INVALID_ID) return super.onSingleTapConfirmed(e)
+
+            val pdfCoordinates =
+                pageLayoutManager.getPageCoordinatesRelativeToTappedPage(
+                    pageNum,
+                    getVisibleAreaInContentCoords(),
+                    PointF(contentX, contentY)
+                )
+
+            pageManager?.getLinkAtTapPoint(PdfPoint(pageNum, pdfCoordinates))?.let { links ->
+                if (handleGotoLinks(links, pdfCoordinates)) return true
+                if (handleExternalLinks(links, pdfCoordinates)) return true
+            }
+            return super.onSingleTapConfirmed(e)
+        }
+
+        private fun handleGotoLinks(
+            links: PdfDocument.PdfPageLinks,
+            pdfCoordinates: PointF
+        ): Boolean {
+            links.gotoLinks.forEach { gotoLink ->
+                if (gotoLink.bounds.any { it.contains(pdfCoordinates.x, pdfCoordinates.y) }) {
+                    val destination =
+                        PdfPoint(
+                            pageNum = gotoLink.destination.pageNumber,
+                            pagePoint =
+                                PointF(
+                                    gotoLink.destination.xCoordinate,
+                                    gotoLink.destination.yCoordinate
+                                )
+                        )
+                    gotoPoint(destination)
+                    return true
+                }
+            }
+            return false
+        }
+
+        private fun handleExternalLinks(
+            links: PdfDocument.PdfPageLinks,
+            pdfCoordinates: PointF
+        ): Boolean {
+            links.externalLinks.forEach { externalLink ->
+                if (externalLink.bounds.any { it.contains(pdfCoordinates.x, pdfCoordinates.y) }) {
+                    linkClickListener?.onLinkClicked(externalLink.uri)
+                        ?: run {
+                            val intent = Intent(Intent.ACTION_VIEW, externalLink.uri)
+                            context.startActivity(intent)
+                        }
+                    return true
+                }
+            }
+            return false
+        }
+    }
+
     public companion object {
         public const val DEFAULT_INIT_ZOOM: Float = 1.0f
         public const val DEFAULT_MAX_ZOOM: Float = 25.0f
         public const val DEFAULT_MIN_ZOOM: Float = 0.1f
 
+        /** The ratio of vertical to horizontal scroll that is assumed to be vertical only */
+        private const val SCROLL_CORRECTION_RATIO = 1.5f
+        /** The maximum scroll distance used to determine if the direction is vertical */
+        private const val MAX_SCROLL_WINDOW_DP = 70
+        /** The smallest scroll distance that can switch mode to "free scrolling" */
+        private const val MIN_SCROLL_TO_SWITCH_DP = 30
+
         private const val DEFAULT_PAGE_PREFETCH_RADIUS: Int = 2
+        private const val INVALID_ID = -1
 
         private fun checkMainThread() {
             check(Looper.myLooper() == Looper.getMainLooper()) {
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
index e5b1cc7..8b13789 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
@@ -1,170 +1 @@
-/*
- * Copyright 2024 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 androidx.pdf.view
-
-import android.graphics.PointF
-import android.view.MotionEvent
-import android.view.ScaleGestureDetector
-import java.util.LinkedList
-import java.util.Queue
-import kotlin.math.abs
-
-/** Adjusts the position of [PdfView] in response to gestures detected by [GestureTracker] */
-internal class ZoomScrollGestureHandler(private val pdfView: PdfView) :
-    GestureTracker.GestureHandler() {
-    internal var scrollInProgress = false
-    internal var scaleInProgress = false
-
-    /**
-     * The multiplier to convert from a scale gesture's delta span, in pixels, to scale factor.
-     *
-     * [ScaleGestureDetector] returns scale factors proportional to the ratio of `currentSpan /
-     * prevSpan`. This is problematic because it results in scale factors that are very large for
-     * small pixel spans, which is particularly problematic for quickScale gestures, where the span
-     * pixel values can be small, but the ratio can yield very large scale factors.
-     *
-     * Instead, we use this to ensure that pinching or quick scale dragging a certain number of
-     * pixels always corresponds to a certain change in zoom. The equation that we've found to work
-     * well is a delta span of the larger screen dimension should result in a zoom change of 2x.
-     */
-    private val linearScaleSpanMultiplier: Float =
-        2f /
-            maxOf(
-                pdfView.resources.displayMetrics.heightPixels,
-                pdfView.resources.displayMetrics.widthPixels
-            )
-    /** The maximum scroll distance used to determine if the direction is vertical. */
-    private val maxScrollWindow =
-        (pdfView.resources.displayMetrics.density * MAX_SCROLL_WINDOW_DP).toInt()
-
-    /** The smallest scroll distance that can switch mode to "free scrolling". */
-    private val minScrollToSwitch =
-        (pdfView.resources.displayMetrics.density * MIN_SCROLL_TO_SWITCH_DP).toInt()
-
-    /** Remember recent scroll events so we can examine the general direction. */
-    private val scrollQueue: Queue<PointF> = LinkedList()
-
-    /** Are we correcting vertical scroll for the current gesture? */
-    private var straightenCurrentVerticalScroll = true
-
-    private var totalX = 0f
-    private var totalY = 0f
-
-    private val totalScrollLength
-        // No need for accuracy of correct hypotenuse calculation
-        get() = abs(totalX) + abs(totalY)
-
-    override fun onScroll(
-        e1: MotionEvent?,
-        e2: MotionEvent,
-        distanceX: Float,
-        distanceY: Float,
-    ): Boolean {
-        scrollInProgress = true
-        var dx = Math.round(distanceX)
-        val dy = Math.round(distanceY)
-
-        if (straightenCurrentVerticalScroll) {
-            // Remember a window of recent scroll events.
-            scrollQueue.offer(PointF(distanceX, distanceY))
-            totalX += distanceX
-            totalY += distanceY
-
-            // Only consider scroll direction for a certain window of scroll events.
-            while (totalScrollLength > maxScrollWindow && scrollQueue.size > 1) {
-                // Remove the oldest scroll event - it is too far away to determine scroll
-                // direction.
-                val oldest = scrollQueue.poll()
-                oldest?.let {
-                    totalY -= oldest.y
-                    totalX -= oldest.x
-                }
-            }
-
-            if (
-                totalScrollLength > minScrollToSwitch &&
-                    abs((totalY / totalX).toDouble()) < SCROLL_CORRECTION_RATIO
-            ) {
-                straightenCurrentVerticalScroll = false
-            } else {
-                // Ignore the horizontal component of the scroll.
-                dx = 0
-            }
-        }
-
-        pdfView.scrollBy(dx, dy)
-        return true
-    }
-
-    override fun onFling(
-        e1: MotionEvent?,
-        e2: MotionEvent,
-        velocityX: Float,
-        velocityY: Float
-    ): Boolean {
-        return super.onFling(e1, e2, velocityX, velocityY)
-        // TODO(b/376136621) Animate scroll position during a fling
-    }
-
-    override fun onDoubleTap(e: MotionEvent): Boolean {
-        return super.onDoubleTap(e)
-        // TODO(b/376136331) Toggle between fit-to-page and zoomed-in on double tap gestures
-    }
-
-    override fun onScale(detector: ScaleGestureDetector): Boolean {
-        scaleInProgress = true
-        val rawScaleFactor = detector.scaleFactor
-        val deltaSpan = abs(detector.currentSpan - detector.previousSpan)
-        val scaleDelta = deltaSpan * linearScaleSpanMultiplier
-        val linearScaleFactor = if (rawScaleFactor >= 1f) 1f + scaleDelta else 1f - scaleDelta
-        val newZoom = (pdfView.zoom * linearScaleFactor).coerceIn(pdfView.minZoom, pdfView.maxZoom)
-
-        pdfView.zoomTo(newZoom, detector.focusX, detector.focusY)
-        return true
-    }
-
-    override fun onGestureEnd(gesture: GestureTracker.Gesture?) {
-        when (gesture) {
-            GestureTracker.Gesture.ZOOM -> {
-                scaleInProgress = false
-                pdfView.onZoomChanged()
-            }
-            GestureTracker.Gesture.DRAG,
-            GestureTracker.Gesture.DRAG_Y,
-            GestureTracker.Gesture.DRAG_X -> {
-                scrollInProgress = false
-                pdfView.onZoomChanged()
-            }
-            else -> {
-                /* no-op */
-            }
-        }
-        totalX = 0f
-        totalY = 0f
-        straightenCurrentVerticalScroll = true
-        scrollQueue.clear()
-    }
-
-    companion object {
-        /** The ratio of vertical to horizontal scroll that is assumed to be vertical only */
-        private const val SCROLL_CORRECTION_RATIO = 1.5f
-        /** The maximum scroll distance used to determine if the direction is vertical */
-        private const val MAX_SCROLL_WINDOW_DP = 70
-        /** The smallest scroll distance that can switch mode to "free scrolling" */
-        private const val MIN_SCROLL_TO_SWITCH_DP = 30
-    }
-}
diff --git a/pdf/pdf-viewer/src/main/res/values/strings.xml b/pdf/pdf-viewer/src/main/res/values/strings.xml
index f4d7e82..9c0abaf 100644
--- a/pdf/pdf-viewer/src/main/res/values/strings.xml
+++ b/pdf/pdf-viewer/src/main/res/values/strings.xml
@@ -60,6 +60,9 @@
     <!-- Content description for an empty page of a paginated document -->
     <string name="desc_empty_page">Empty page</string>
 
+    <!-- Content description for a page with its page number and corresponding text content -->
+    <string name="desc_page_with_text">Page <xliff:g example="3" id="page_number">%1$d</xliff:g>: <xliff:g example="This is the text" id="page_text">%2$s</xliff:g></string>
+
     <!-- Error message when file format isn't valid PDF. [CHAR LIMIT=60] -->
     <string name="error_file_format_pdf">Cannot display PDF ("<xliff:g example="Treasure Island" id="title">%1$s</xliff:g>" is of invalid format)</string>
 
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/BitmapFetcherTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/BitmapFetcherTest.kt
new file mode 100644
index 0000000..fc60503
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/BitmapFetcherTest.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2024 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 androidx.pdf.view
+
+import android.graphics.Point
+import androidx.pdf.PdfDocument
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.roundToInt
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class BitmapFetcherTest {
+    private val testDispatcher = StandardTestDispatcher()
+    private val testScope = TestScope(testDispatcher)
+    private val pdfDocument =
+        mock<PdfDocument> {
+            on { getPageBitmapSource(any()) } doAnswer
+                { invocation ->
+                    FakeBitmapSource(invocation.getArgument(0))
+                }
+        }
+
+    private var invalidationCounter = 0
+    private val invalidationTracker: () -> Unit = { invalidationCounter++ }
+
+    private val maxBitmapSizePx = Point(2048, 2048)
+    private val pageSize = Point(512, 512)
+
+    private lateinit var bitmapFetcher: BitmapFetcher
+    private lateinit var tileSizePx: Point
+
+    @Before
+    fun setup() {
+        testDispatcher.cancelChildren()
+        invalidationCounter = 0
+
+        bitmapFetcher =
+            BitmapFetcher(
+                pageNum = 0,
+                pageSize,
+                pdfDocument,
+                testScope,
+                maxBitmapSizePx,
+                invalidationTracker,
+            )
+        bitmapFetcher.isActive = true
+        tileSizePx = BitmapFetcher.tileSizePx
+    }
+
+    @Test
+    fun setInactive_cancelsWorkAndFreesBitmaps() {
+        bitmapFetcher.onScaleChanged(1.5f)
+        assertThat(bitmapFetcher.renderingJob?.isActive).isTrue()
+
+        bitmapFetcher.isActive = false
+        assertThat(bitmapFetcher.renderingJob).isNull()
+        assertThat(bitmapFetcher.pageContents).isNull()
+    }
+
+    @Test
+    fun setScale_rendersFullPageBitmap() {
+        bitmapFetcher.onScaleChanged(1.5f)
+
+        testDispatcher.scheduler.runCurrent()
+
+        val pageBitmaps = bitmapFetcher.pageContents
+        assertThat(pageBitmaps).isInstanceOf(FullPageBitmap::class.java)
+        assertThat(pageBitmaps?.renderedScale).isEqualTo(1.5f)
+        pageBitmaps as FullPageBitmap // Make smartcast work nicely below
+        assertThat(pageBitmaps.bitmap.width).isEqualTo((pageSize.x * 1.5f).roundToInt())
+        assertThat(pageBitmaps.bitmap.height).isEqualTo((pageSize.y * 1.5f).roundToInt())
+        assertThat(invalidationCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setScale_rendersTileBoard() {
+        bitmapFetcher.onScaleChanged(5.0f)
+
+        testDispatcher.scheduler.runCurrent()
+
+        val pageBitmaps = bitmapFetcher.pageContents
+        assertThat(pageBitmaps).isInstanceOf(TileBoard::class.java)
+        assertThat(pageBitmaps?.renderedScale).isEqualTo(5.0f)
+        pageBitmaps as TileBoard // Make smartcast work nicely below
+
+        // Check the properties of an arbitrary full-size tile
+        val row0Col2 = pageBitmaps.tiles[2]
+        assertThat(row0Col2.bitmap?.width).isEqualTo(tileSizePx.x)
+        assertThat(row0Col2.bitmap?.height).isEqualTo(tileSizePx.y)
+        assertThat(row0Col2.offsetPx).isEqualTo(Point(tileSizePx.x * 2, 0))
+        assertThat(row0Col2.exactSizePx).isEqualTo(Point(tileSizePx.x, tileSizePx.y))
+
+        // Check the properties of the last tile in the bottom right corner
+        val row3Col3 = pageBitmaps.tiles[15]
+        val pageSizePx = Point(pageSize.x * 5, pageSize.y * 5)
+        // This tile is not the same size as the others b/c it's cut off on both edges
+        val row3Col3Size = Point(pageSizePx.x - tileSizePx.x * 3, pageSizePx.y - tileSizePx.y * 3)
+        assertThat(row3Col3.bitmap?.width).isEqualTo(row3Col3Size.x)
+        assertThat(row3Col3.bitmap?.height).isEqualTo(row3Col3Size.y)
+        assertThat(row3Col3.offsetPx).isEqualTo(Point(tileSizePx.x * 3, tileSizePx.y * 3))
+        assertThat(row3Col3.exactSizePx).isEqualTo(row3Col3Size)
+
+        // 1 invalidation for the low-res background, 1 for each tile * 16 tiles
+        assertThat(invalidationCounter).isEqualTo(17)
+    }
+
+    @Test
+    fun setScale_toRenderedValue_noNewWork() {
+        bitmapFetcher.isActive = true
+
+        bitmapFetcher.onScaleChanged(1.5f)
+        testDispatcher.scheduler.runCurrent()
+        val firstBitmaps = bitmapFetcher.pageContents
+        bitmapFetcher.onScaleChanged(1.5f)
+
+        // We shouldn't have started a new Job the second time onScaleChanged to the same value
+        assertThat(bitmapFetcher.renderingJob).isNull()
+        // And we should still have the same bitmaps
+        assertThat(bitmapFetcher.pageContents).isEqualTo(firstBitmaps)
+        // 1 total invalidation
+        assertThat(invalidationCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setScale_toRenderingValue_noNewWork() {
+        bitmapFetcher.onScaleChanged(1.5f)
+        val firstJob = bitmapFetcher.renderingJob
+        bitmapFetcher.onScaleChanged(1.5f)
+
+        // This should be the same Job we started the first time onScaleChanged
+        assertThat(bitmapFetcher.renderingJob).isEqualTo(firstJob)
+        // 0 invalidations because we're still rendering
+        assertThat(invalidationCounter).isEqualTo(0)
+    }
+
+    @Test
+    fun setScale_afterInactive_rendersNewBitmaps() {
+        bitmapFetcher.onScaleChanged(1.5f)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(bitmapFetcher.pageContents).isNotNull()
+        assertThat(invalidationCounter).isEqualTo(1)
+
+        bitmapFetcher.isActive = false
+        assertThat(bitmapFetcher.pageContents).isNull()
+
+        bitmapFetcher.isActive = true
+        bitmapFetcher.onScaleChanged(1.5f)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(bitmapFetcher.pageContents).isNotNull()
+        assertThat(invalidationCounter).isEqualTo(2)
+    }
+
+    @Test
+    fun setScale_fromFullPage_toTiled() {
+        bitmapFetcher.onScaleChanged(1.5f)
+        testDispatcher.scheduler.runCurrent()
+        val fullPageBitmap = bitmapFetcher.pageContents
+        assertThat(fullPageBitmap).isInstanceOf(FullPageBitmap::class.java)
+        assertThat(fullPageBitmap?.renderedScale).isEqualTo(1.5f)
+        assertThat(invalidationCounter).isEqualTo(1)
+
+        bitmapFetcher.onScaleChanged(5.0f)
+        testDispatcher.scheduler.runCurrent()
+        val tileBoard = bitmapFetcher.pageContents
+        assertThat(tileBoard).isInstanceOf(TileBoard::class.java)
+        assertThat(tileBoard?.renderedScale).isEqualTo(5.0f)
+        // 1 invalidation for the previous full page bitmap + 1 for the low res background
+        // + (1 for each tile * 16 tiles)
+        assertThat(invalidationCounter).isEqualTo(18)
+    }
+
+    @Test
+    fun setScale_fromTiled_toFullPage() {
+        bitmapFetcher.onScaleChanged(5.0f)
+        testDispatcher.scheduler.runCurrent()
+        val tileBoard = bitmapFetcher.pageContents
+        assertThat(tileBoard).isInstanceOf(TileBoard::class.java)
+        assertThat(tileBoard?.renderedScale).isEqualTo(5.0f)
+        // 1 invalidation for the low res background + (1 for each tile * 16 tiles)
+        assertThat(invalidationCounter).isEqualTo(17)
+
+        bitmapFetcher.onScaleChanged(1.5f)
+        testDispatcher.scheduler.runCurrent()
+        val fullPageBitmap = bitmapFetcher.pageContents
+        assertThat(fullPageBitmap).isInstanceOf(FullPageBitmap::class.java)
+        assertThat(fullPageBitmap?.renderedScale).isEqualTo(1.5f)
+        // 1 additional invalidation for the new full page bitmap
+        assertThat(invalidationCounter).isEqualTo(18)
+    }
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/FakeBitmapSource.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/FakeBitmapSource.kt
new file mode 100644
index 0000000..5433bcb
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/FakeBitmapSource.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 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 androidx.pdf.view
+
+import android.graphics.Bitmap
+import android.graphics.Rect
+import android.util.Size
+import androidx.pdf.PdfDocument
+
+/**
+ * Fake implementation of [PdfDocument.BitmapSource] that always produces a blank bitmap of the
+ * requested size.
+ */
+internal class FakeBitmapSource(override val pageNumber: Int) : PdfDocument.BitmapSource {
+
+    override suspend fun getBitmap(scaledPageSizePx: Size, tileRegion: Rect?): Bitmap {
+        return if (tileRegion != null) {
+            Bitmap.createBitmap(tileRegion.width(), tileRegion.height(), Bitmap.Config.ARGB_8888)
+        } else {
+            Bitmap.createBitmap(
+                scaledPageSizePx.width,
+                scaledPageSizePx.height,
+                Bitmap.Config.ARGB_8888
+            )
+        }
+    }
+
+    override fun close() {
+        /* no-op, fake */
+    }
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PageTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PageTest.kt
index 3abba3b..3a73055 100644
--- a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PageTest.kt
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PageTest.kt
@@ -16,14 +16,13 @@
 
 package androidx.pdf.view
 
-import android.graphics.Bitmap
 import android.graphics.Canvas
 import android.graphics.Color
 import android.graphics.Point
 import android.graphics.Rect
 import android.graphics.RectF
-import android.util.Size
 import androidx.pdf.PdfDocument
+import androidx.pdf.content.PdfPageTextContent
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.test.StandardTestDispatcher
@@ -34,6 +33,7 @@
 import org.mockito.kotlin.any
 import org.mockito.kotlin.argThat
 import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.isNull
 import org.mockito.kotlin.mock
@@ -45,18 +45,29 @@
 class PageTest {
     private val testDispatcher = StandardTestDispatcher()
     private val testScope = TestScope(testDispatcher)
+    private val pageContent =
+        PdfDocument.PdfPageContent(
+            listOf(PdfPageTextContent(listOf(RectF(10f, 10f, 50f, 20f)), "SampleText")),
+            emptyList() // No images in this test case
+        )
+
     private val pdfDocument =
         mock<PdfDocument> {
             on { getPageBitmapSource(any()) } doAnswer
                 { invocation ->
                     FakeBitmapSource(invocation.getArgument(0))
                 }
+            onBlocking { getPageContent(pageNumber = 0) } doReturn pageContent
         }
 
     private val canvasSpy = spy(Canvas())
 
     private var invalidationCounter = 0
     private val invalidationTracker: () -> Unit = { invalidationCounter++ }
+
+    private var pageTextReadyCounter = 0
+    private val onPageTextReady: ((Int) -> Unit) = { _ -> pageTextReadyCounter++ }
+
     private lateinit var page: Page
 
     @Before
@@ -64,89 +75,18 @@
         // Cancel any work from previous tests, and reset tracking variables
         testDispatcher.cancelChildren()
         invalidationCounter = 0
+        pageTextReadyCounter = 0
 
-        page = Page(0, size = PAGE_SIZE, pdfDocument, testScope, invalidationTracker)
-    }
-
-    @Test
-    fun setVisible_fromInvisible() {
-        // Set the page to visible
-        page.setVisible(zoom = 1.0F)
-
-        // Make sure we start and finish fetching a Bitmap
-        assertThat(page.renderBitmapJob).isNotNull()
-        testDispatcher.scheduler.runCurrent()
-        assertThat(page.renderBitmapJob).isNull()
-
-        // Make we fetched the right bitmap
-        assertThat(page.bitmap).isNotNull()
-        assertThat(page.bitmap?.width).isEqualTo(PAGE_SIZE.x)
-        assertThat(page.bitmap?.height).isEqualTo(PAGE_SIZE.y)
-    }
-
-    @Test
-    fun setVisible_fromVisible_noNewBitmaps() {
-        // Set the page to visible once, and make sure we fetched the correct Bitmap
-        page.setVisible(zoom = 1.0F)
-
-        testDispatcher.scheduler.runCurrent()
-        assertThat(page.bitmap).isNotNull()
-        assertThat(page.bitmap?.width).isEqualTo(PAGE_SIZE.x)
-        assertThat(page.bitmap?.height).isEqualTo(PAGE_SIZE.y)
-
-        // Set the page to visible again, at the same zoom level, and make sure we don't fetch or
-        // start fetching a new Bitmap
-        page.setVisible(zoom = 1.0F)
-
-        assertThat(page.renderBitmapJob).isNull()
-        assertThat(page.bitmap).isNotNull()
-        assertThat(page.bitmap?.width).isEqualTo(PAGE_SIZE.x)
-        assertThat(page.bitmap?.height).isEqualTo(PAGE_SIZE.y)
-
-        // 1 total invalidation from 1 Bitmap prepared
-        assertThat(invalidationCounter).isEqualTo(1)
-    }
-
-    @Test
-    fun setVisible_fromVisible_fetchNewBitmaps() {
-        // Set the page to visible once, at 1.0 zoom, and make sure we fetched the correct Bitmap
-        page.setVisible(zoom = 1.0F)
-
-        testDispatcher.scheduler.runCurrent()
-        assertThat(page.bitmap).isNotNull()
-        assertThat(page.bitmap?.width).isEqualTo(PAGE_SIZE.x)
-        assertThat(page.bitmap?.height).isEqualTo(PAGE_SIZE.y)
-        assertThat(invalidationCounter).isEqualTo(1)
-
-        // Set the page to visible again, but this time at 2.0 zoom, and make sure we fetch a
-        // _new_ Bitmap
-        page.setVisible(zoom = 2.0F)
-
-        assertThat(page.renderBitmapJob).isNotNull()
-        testDispatcher.scheduler.runCurrent()
-        assertThat(page.bitmap).isNotNull()
-        assertThat(page.bitmap?.width).isEqualTo(PAGE_SIZE.x * 2)
-        assertThat(page.bitmap?.height).isEqualTo(PAGE_SIZE.y * 2)
-
-        // 2 total invalidations from 2 Bitmaps prepared
-        assertThat(invalidationCounter).isEqualTo(2)
-    }
-
-    @Test
-    fun setInvisible() {
-        // Set the page to visible, and make sure we start fetching a bitmap
-        page.setVisible(zoom = 1.0F)
-        assertThat(page.renderBitmapJob).isNotNull()
-
-        // Set the page to invisible, make sure we stop fetching the bitmap, and make sure internal
-        // state is updated appropriately
-        page.setInvisible()
-        testDispatcher.scheduler.runCurrent()
-        assertThat(page.renderBitmapJob).isNull()
-        assertThat(page.bitmap).isNull()
-
-        // 0 invalidations, as the initial job to fetch a Bitmap should have been cancelled
-        assertThat(invalidationCounter).isEqualTo(0)
+        page =
+            Page(
+                0,
+                pageSizePx = PAGE_SIZE,
+                pdfDocument,
+                testScope,
+                MAX_BITMAP_SIZE,
+                invalidationTracker,
+                onPageTextReady
+            )
     }
 
     @Test
@@ -211,29 +151,37 @@
         // Mockito's Spy functionality, as it captures arguments by reference, and we re-use
         // Rect and Paint arguments to canvas.drawRect() to avoid allocations on the drawing path
     }
+
+    @Test
+    fun setVisible_fetchesPageText() {
+        page.setVisible(zoom = 1.0f)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(page.pageText).isEqualTo("SampleText")
+        assertThat(pageTextReadyCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setVisible_doesNotFetchPageTextIfAlreadyFetched() {
+        page.setVisible(zoom = 1.0f)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(page.pageText).isEqualTo("SampleText")
+        assertThat(pageTextReadyCounter).isEqualTo(1)
+
+        page.setVisible(zoom = 1.0f)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(page.pageText).isEqualTo("SampleText")
+        assertThat(pageTextReadyCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setInvisible_cancelsPageTextFetch() {
+        page.setVisible(zoom = 1.0f)
+        page.setInvisible()
+        testDispatcher.scheduler.runCurrent()
+        assertThat(page.pageText).isNull()
+        assertThat(pageTextReadyCounter).isEqualTo(0)
+    }
 }
 
 val PAGE_SIZE = Point(100, 150)
-
-/**
- * Fake implementation of [PdfDocument.BitmapSource] that always produces a blank bitmap of the
- * requested size.
- */
-private class FakeBitmapSource(override val pageNumber: Int) : PdfDocument.BitmapSource {
-
-    override suspend fun getBitmap(scaledPageSizePx: Size, tileRegion: Rect?): Bitmap {
-        return if (tileRegion != null) {
-            Bitmap.createBitmap(tileRegion.width(), tileRegion.height(), Bitmap.Config.ARGB_8888)
-        } else {
-            Bitmap.createBitmap(
-                scaledPageSizePx.width,
-                scaledPageSizePx.height,
-                Bitmap.Config.ARGB_8888
-            )
-        }
-    }
-
-    override fun close() {
-        /* no-op, fake */
-    }
-}
+val MAX_BITMAP_SIZE = Point(500, 500)
diff --git a/playground-common/androidx-shared.properties b/playground-common/androidx-shared.properties
index 6a94086..7b2e789 100644
--- a/playground-common/androidx-shared.properties
+++ b/playground-common/androidx-shared.properties
@@ -64,7 +64,7 @@
 android.experimental.dependency.excludeLibraryComponentsFromConstraints=true
 # Disallow resolving dependencies at configuration time, which is a slight performance problem
 android.dependencyResolutionAtConfigurationTime.disallow=true
-android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.lint.reservedMemoryPerTask,android.experimental.dependency.excludeLibraryComponentsFromConstraints,android.prefabVersion,android.experimental.privacysandboxsdk.plugin.enable,android.experimental.privacysandboxsdk.requireServices,android.lint.useK2Uast
+android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.lint.reservedMemoryPerTask,android.experimental.dependency.excludeLibraryComponentsFromConstraints,android.prefabVersion,android.experimental.privacysandboxsdk.plugin.enable,android.experimental.privacysandboxsdk.requireServices,android.lint.useK2Uast,android.experimental.skipApksViaBundleIfPossible
 # Workaround for b/162074215
 android.includeDependencyInfoInApks=false
 
@@ -88,3 +88,5 @@
 android.experimental.privacysandboxsdk.plugin.enable=true
 # Allow non-shim usage
 android.experimental.privacysandboxsdk.requireServices=false
+# Use fast-path APKs from AGP, ensuring that single APK will be used (for FTL configs)
+android.experimental.skipApksViaBundleIfPossible=true
diff --git a/preference/preference/build.gradle b/preference/preference/build.gradle
index b4bd3f9..dda828a 100644
--- a/preference/preference/build.gradle
+++ b/preference/preference/build.gradle
@@ -46,13 +46,6 @@
 }
 
 android {
-    sourceSets {
-        main.res.srcDirs = [
-                "res",
-                "res-public"
-        ]
-    }
-
     defaultConfig {
         vectorDrawables.useSupportLibrary = true
     }
diff --git a/preference/preference/res/layout/preference_list_fragment.xml b/preference/preference/res/layout/preference_list_fragment.xml
deleted file mode 100644
index c2ae115..0000000
--- a/preference/preference/res/layout/preference_list_fragment.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  ~ Copyright (C) 2015 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.
-  -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="NewApi"
-    android:orientation="vertical"
-    android:layout_height="match_parent"
-    android:layout_width="match_parent" >
-
-    <FrameLayout
-        android:id="@android:id/list_container"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_weight="1" />
-
-    <TextView android:id="@android:id/empty"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:padding="8dp"
-        android:gravity="center"
-        android:visibility="gone" />
-
-</LinearLayout>
diff --git a/preference/preference/res/drawable-v21/ic_arrow_down_24dp.xml b/preference/preference/src/main/res/drawable-v21/ic_arrow_down_24dp.xml
similarity index 100%
rename from preference/preference/res/drawable-v21/ic_arrow_down_24dp.xml
rename to preference/preference/src/main/res/drawable-v21/ic_arrow_down_24dp.xml
diff --git a/preference/preference/res/drawable-v21/preference_list_divider_material.xml b/preference/preference/src/main/res/drawable-v21/preference_list_divider_material.xml
similarity index 100%
rename from preference/preference/res/drawable-v21/preference_list_divider_material.xml
rename to preference/preference/src/main/res/drawable-v21/preference_list_divider_material.xml
diff --git a/preference/preference/res/drawable/ic_arrow_down_24dp.xml b/preference/preference/src/main/res/drawable/ic_arrow_down_24dp.xml
similarity index 100%
rename from preference/preference/res/drawable/ic_arrow_down_24dp.xml
rename to preference/preference/src/main/res/drawable/ic_arrow_down_24dp.xml
diff --git a/preference/preference/res/drawable/preference_list_divider_material.xml b/preference/preference/src/main/res/drawable/preference_list_divider_material.xml
similarity index 100%
rename from preference/preference/res/drawable/preference_list_divider_material.xml
rename to preference/preference/src/main/res/drawable/preference_list_divider_material.xml
diff --git a/preference/preference/res/layout/expand_button.xml b/preference/preference/src/main/res/layout/expand_button.xml
similarity index 100%
rename from preference/preference/res/layout/expand_button.xml
rename to preference/preference/src/main/res/layout/expand_button.xml
diff --git a/preference/preference/res/layout/image_frame.xml b/preference/preference/src/main/res/layout/image_frame.xml
similarity index 100%
rename from preference/preference/res/layout/image_frame.xml
rename to preference/preference/src/main/res/layout/image_frame.xml
diff --git a/preference/preference/res/layout/preference.xml b/preference/preference/src/main/res/layout/preference.xml
similarity index 100%
rename from preference/preference/res/layout/preference.xml
rename to preference/preference/src/main/res/layout/preference.xml
diff --git a/preference/preference/res/layout/preference_category.xml b/preference/preference/src/main/res/layout/preference_category.xml
similarity index 100%
rename from preference/preference/res/layout/preference_category.xml
rename to preference/preference/src/main/res/layout/preference_category.xml
diff --git a/preference/preference/res/layout/preference_category_material.xml b/preference/preference/src/main/res/layout/preference_category_material.xml
similarity index 100%
rename from preference/preference/res/layout/preference_category_material.xml
rename to preference/preference/src/main/res/layout/preference_category_material.xml
diff --git a/preference/preference/res/layout/preference_dialog_edittext.xml b/preference/preference/src/main/res/layout/preference_dialog_edittext.xml
similarity index 100%
rename from preference/preference/res/layout/preference_dialog_edittext.xml
rename to preference/preference/src/main/res/layout/preference_dialog_edittext.xml
diff --git a/preference/preference/res/layout/preference_dropdown.xml b/preference/preference/src/main/res/layout/preference_dropdown.xml
similarity index 100%
rename from preference/preference/res/layout/preference_dropdown.xml
rename to preference/preference/src/main/res/layout/preference_dropdown.xml
diff --git a/preference/preference/res/layout/preference_dropdown_material.xml b/preference/preference/src/main/res/layout/preference_dropdown_material.xml
similarity index 100%
rename from preference/preference/res/layout/preference_dropdown_material.xml
rename to preference/preference/src/main/res/layout/preference_dropdown_material.xml
diff --git a/preference/preference/res/layout/preference_information.xml b/preference/preference/src/main/res/layout/preference_information.xml
similarity index 100%
rename from preference/preference/res/layout/preference_information.xml
rename to preference/preference/src/main/res/layout/preference_information.xml
diff --git a/preference/preference/res/layout/preference_information_material.xml b/preference/preference/src/main/res/layout/preference_information_material.xml
similarity index 100%
rename from preference/preference/res/layout/preference_information_material.xml
rename to preference/preference/src/main/res/layout/preference_information_material.xml
diff --git a/preference/preference/src/main/res/layout/preference_list_fragment.xml b/preference/preference/src/main/res/layout/preference_list_fragment.xml
new file mode 100644
index 0000000..ad03481
--- /dev/null
+++ b/preference/preference/src/main/res/layout/preference_list_fragment.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2015 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.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="NewApi"
+    android:orientation="vertical"
+    android:fitsSystemWindows="true"
+    android:layout_height="match_parent"
+    android:layout_width="match_parent" >
+
+    <FrameLayout
+        android:id="@android:id/list_container"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1" />
+
+    <TextView android:id="@android:id/empty"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:padding="8dp"
+        android:gravity="center"
+        android:visibility="gone" />
+
+</LinearLayout>
diff --git a/preference/preference/res/layout/preference_material.xml b/preference/preference/src/main/res/layout/preference_material.xml
similarity index 100%
rename from preference/preference/res/layout/preference_material.xml
rename to preference/preference/src/main/res/layout/preference_material.xml
diff --git a/preference/preference/res/layout/preference_recyclerview.xml b/preference/preference/src/main/res/layout/preference_recyclerview.xml
similarity index 100%
rename from preference/preference/res/layout/preference_recyclerview.xml
rename to preference/preference/src/main/res/layout/preference_recyclerview.xml
diff --git a/preference/preference/res/layout/preference_widget_checkbox.xml b/preference/preference/src/main/res/layout/preference_widget_checkbox.xml
similarity index 100%
rename from preference/preference/res/layout/preference_widget_checkbox.xml
rename to preference/preference/src/main/res/layout/preference_widget_checkbox.xml
diff --git a/preference/preference/res/layout/preference_widget_seekbar.xml b/preference/preference/src/main/res/layout/preference_widget_seekbar.xml
similarity index 100%
rename from preference/preference/res/layout/preference_widget_seekbar.xml
rename to preference/preference/src/main/res/layout/preference_widget_seekbar.xml
diff --git a/preference/preference/res/layout/preference_widget_seekbar_material.xml b/preference/preference/src/main/res/layout/preference_widget_seekbar_material.xml
similarity index 100%
rename from preference/preference/res/layout/preference_widget_seekbar_material.xml
rename to preference/preference/src/main/res/layout/preference_widget_seekbar_material.xml
diff --git a/preference/preference/res/layout/preference_widget_switch.xml b/preference/preference/src/main/res/layout/preference_widget_switch.xml
similarity index 100%
rename from preference/preference/res/layout/preference_widget_switch.xml
rename to preference/preference/src/main/res/layout/preference_widget_switch.xml
diff --git a/preference/preference/res/layout/preference_widget_switch_compat.xml b/preference/preference/src/main/res/layout/preference_widget_switch_compat.xml
similarity index 100%
rename from preference/preference/res/layout/preference_widget_switch_compat.xml
rename to preference/preference/src/main/res/layout/preference_widget_switch_compat.xml
diff --git a/preference/preference/res/values-af/strings.xml b/preference/preference/src/main/res/values-af/strings.xml
similarity index 100%
rename from preference/preference/res/values-af/strings.xml
rename to preference/preference/src/main/res/values-af/strings.xml
diff --git a/preference/preference/res/values-am/strings.xml b/preference/preference/src/main/res/values-am/strings.xml
similarity index 100%
rename from preference/preference/res/values-am/strings.xml
rename to preference/preference/src/main/res/values-am/strings.xml
diff --git a/preference/preference/res/values-ar/strings.xml b/preference/preference/src/main/res/values-ar/strings.xml
similarity index 100%
rename from preference/preference/res/values-ar/strings.xml
rename to preference/preference/src/main/res/values-ar/strings.xml
diff --git a/preference/preference/res/values-as/strings.xml b/preference/preference/src/main/res/values-as/strings.xml
similarity index 100%
rename from preference/preference/res/values-as/strings.xml
rename to preference/preference/src/main/res/values-as/strings.xml
diff --git a/preference/preference/res/values-az/strings.xml b/preference/preference/src/main/res/values-az/strings.xml
similarity index 100%
rename from preference/preference/res/values-az/strings.xml
rename to preference/preference/src/main/res/values-az/strings.xml
diff --git a/preference/preference/res/values-b+sr+Latn/strings.xml b/preference/preference/src/main/res/values-b+sr+Latn/strings.xml
similarity index 100%
rename from preference/preference/res/values-b+sr+Latn/strings.xml
rename to preference/preference/src/main/res/values-b+sr+Latn/strings.xml
diff --git a/preference/preference/res/values-be/strings.xml b/preference/preference/src/main/res/values-be/strings.xml
similarity index 100%
rename from preference/preference/res/values-be/strings.xml
rename to preference/preference/src/main/res/values-be/strings.xml
diff --git a/preference/preference/res/values-bg/strings.xml b/preference/preference/src/main/res/values-bg/strings.xml
similarity index 100%
rename from preference/preference/res/values-bg/strings.xml
rename to preference/preference/src/main/res/values-bg/strings.xml
diff --git a/preference/preference/res/values-bn/strings.xml b/preference/preference/src/main/res/values-bn/strings.xml
similarity index 100%
rename from preference/preference/res/values-bn/strings.xml
rename to preference/preference/src/main/res/values-bn/strings.xml
diff --git a/preference/preference/res/values-bs/strings.xml b/preference/preference/src/main/res/values-bs/strings.xml
similarity index 100%
rename from preference/preference/res/values-bs/strings.xml
rename to preference/preference/src/main/res/values-bs/strings.xml
diff --git a/preference/preference/res/values-ca/strings.xml b/preference/preference/src/main/res/values-ca/strings.xml
similarity index 100%
rename from preference/preference/res/values-ca/strings.xml
rename to preference/preference/src/main/res/values-ca/strings.xml
diff --git a/preference/preference/res/values-cs/strings.xml b/preference/preference/src/main/res/values-cs/strings.xml
similarity index 100%
rename from preference/preference/res/values-cs/strings.xml
rename to preference/preference/src/main/res/values-cs/strings.xml
diff --git a/preference/preference/res/values-da/strings.xml b/preference/preference/src/main/res/values-da/strings.xml
similarity index 100%
rename from preference/preference/res/values-da/strings.xml
rename to preference/preference/src/main/res/values-da/strings.xml
diff --git a/preference/preference/res/values-de/strings.xml b/preference/preference/src/main/res/values-de/strings.xml
similarity index 100%
rename from preference/preference/res/values-de/strings.xml
rename to preference/preference/src/main/res/values-de/strings.xml
diff --git a/preference/preference/res/values-el/strings.xml b/preference/preference/src/main/res/values-el/strings.xml
similarity index 100%
rename from preference/preference/res/values-el/strings.xml
rename to preference/preference/src/main/res/values-el/strings.xml
diff --git a/preference/preference/res/values-en-rAU/strings.xml b/preference/preference/src/main/res/values-en-rAU/strings.xml
similarity index 100%
rename from preference/preference/res/values-en-rAU/strings.xml
rename to preference/preference/src/main/res/values-en-rAU/strings.xml
diff --git a/preference/preference/res/values-en-rCA/strings.xml b/preference/preference/src/main/res/values-en-rCA/strings.xml
similarity index 100%
rename from preference/preference/res/values-en-rCA/strings.xml
rename to preference/preference/src/main/res/values-en-rCA/strings.xml
diff --git a/preference/preference/res/values-en-rGB/strings.xml b/preference/preference/src/main/res/values-en-rGB/strings.xml
similarity index 100%
rename from preference/preference/res/values-en-rGB/strings.xml
rename to preference/preference/src/main/res/values-en-rGB/strings.xml
diff --git a/preference/preference/res/values-en-rIN/strings.xml b/preference/preference/src/main/res/values-en-rIN/strings.xml
similarity index 100%
rename from preference/preference/res/values-en-rIN/strings.xml
rename to preference/preference/src/main/res/values-en-rIN/strings.xml
diff --git a/preference/preference/res/values-en-rXC/strings.xml b/preference/preference/src/main/res/values-en-rXC/strings.xml
similarity index 100%
rename from preference/preference/res/values-en-rXC/strings.xml
rename to preference/preference/src/main/res/values-en-rXC/strings.xml
diff --git a/preference/preference/res/values-es-rUS/strings.xml b/preference/preference/src/main/res/values-es-rUS/strings.xml
similarity index 100%
rename from preference/preference/res/values-es-rUS/strings.xml
rename to preference/preference/src/main/res/values-es-rUS/strings.xml
diff --git a/preference/preference/res/values-es/strings.xml b/preference/preference/src/main/res/values-es/strings.xml
similarity index 100%
rename from preference/preference/res/values-es/strings.xml
rename to preference/preference/src/main/res/values-es/strings.xml
diff --git a/preference/preference/res/values-et/strings.xml b/preference/preference/src/main/res/values-et/strings.xml
similarity index 100%
rename from preference/preference/res/values-et/strings.xml
rename to preference/preference/src/main/res/values-et/strings.xml
diff --git a/preference/preference/res/values-eu/strings.xml b/preference/preference/src/main/res/values-eu/strings.xml
similarity index 100%
rename from preference/preference/res/values-eu/strings.xml
rename to preference/preference/src/main/res/values-eu/strings.xml
diff --git a/preference/preference/res/values-fa/strings.xml b/preference/preference/src/main/res/values-fa/strings.xml
similarity index 100%
rename from preference/preference/res/values-fa/strings.xml
rename to preference/preference/src/main/res/values-fa/strings.xml
diff --git a/preference/preference/res/values-fi/strings.xml b/preference/preference/src/main/res/values-fi/strings.xml
similarity index 100%
rename from preference/preference/res/values-fi/strings.xml
rename to preference/preference/src/main/res/values-fi/strings.xml
diff --git a/preference/preference/res/values-fr-rCA/strings.xml b/preference/preference/src/main/res/values-fr-rCA/strings.xml
similarity index 100%
rename from preference/preference/res/values-fr-rCA/strings.xml
rename to preference/preference/src/main/res/values-fr-rCA/strings.xml
diff --git a/preference/preference/res/values-fr/strings.xml b/preference/preference/src/main/res/values-fr/strings.xml
similarity index 100%
rename from preference/preference/res/values-fr/strings.xml
rename to preference/preference/src/main/res/values-fr/strings.xml
diff --git a/preference/preference/res/values-gl/strings.xml b/preference/preference/src/main/res/values-gl/strings.xml
similarity index 100%
rename from preference/preference/res/values-gl/strings.xml
rename to preference/preference/src/main/res/values-gl/strings.xml
diff --git a/preference/preference/res/values-gu/strings.xml b/preference/preference/src/main/res/values-gu/strings.xml
similarity index 100%
rename from preference/preference/res/values-gu/strings.xml
rename to preference/preference/src/main/res/values-gu/strings.xml
diff --git a/preference/preference/res/values-hi/strings.xml b/preference/preference/src/main/res/values-hi/strings.xml
similarity index 100%
rename from preference/preference/res/values-hi/strings.xml
rename to preference/preference/src/main/res/values-hi/strings.xml
diff --git a/preference/preference/res/values-hr/strings.xml b/preference/preference/src/main/res/values-hr/strings.xml
similarity index 100%
rename from preference/preference/res/values-hr/strings.xml
rename to preference/preference/src/main/res/values-hr/strings.xml
diff --git a/preference/preference/res/values-hu/strings.xml b/preference/preference/src/main/res/values-hu/strings.xml
similarity index 100%
rename from preference/preference/res/values-hu/strings.xml
rename to preference/preference/src/main/res/values-hu/strings.xml
diff --git a/preference/preference/res/values-hy/strings.xml b/preference/preference/src/main/res/values-hy/strings.xml
similarity index 100%
rename from preference/preference/res/values-hy/strings.xml
rename to preference/preference/src/main/res/values-hy/strings.xml
diff --git a/preference/preference/res/values-in/strings.xml b/preference/preference/src/main/res/values-in/strings.xml
similarity index 100%
rename from preference/preference/res/values-in/strings.xml
rename to preference/preference/src/main/res/values-in/strings.xml
diff --git a/preference/preference/res/values-is/strings.xml b/preference/preference/src/main/res/values-is/strings.xml
similarity index 100%
rename from preference/preference/res/values-is/strings.xml
rename to preference/preference/src/main/res/values-is/strings.xml
diff --git a/preference/preference/res/values-it/strings.xml b/preference/preference/src/main/res/values-it/strings.xml
similarity index 100%
rename from preference/preference/res/values-it/strings.xml
rename to preference/preference/src/main/res/values-it/strings.xml
diff --git a/preference/preference/res/values-iw/strings.xml b/preference/preference/src/main/res/values-iw/strings.xml
similarity index 100%
rename from preference/preference/res/values-iw/strings.xml
rename to preference/preference/src/main/res/values-iw/strings.xml
diff --git a/preference/preference/res/values-ja/strings.xml b/preference/preference/src/main/res/values-ja/strings.xml
similarity index 100%
rename from preference/preference/res/values-ja/strings.xml
rename to preference/preference/src/main/res/values-ja/strings.xml
diff --git a/preference/preference/res/values-ka/strings.xml b/preference/preference/src/main/res/values-ka/strings.xml
similarity index 100%
rename from preference/preference/res/values-ka/strings.xml
rename to preference/preference/src/main/res/values-ka/strings.xml
diff --git a/preference/preference/res/values-kk/strings.xml b/preference/preference/src/main/res/values-kk/strings.xml
similarity index 100%
rename from preference/preference/res/values-kk/strings.xml
rename to preference/preference/src/main/res/values-kk/strings.xml
diff --git a/preference/preference/res/values-km/strings.xml b/preference/preference/src/main/res/values-km/strings.xml
similarity index 100%
rename from preference/preference/res/values-km/strings.xml
rename to preference/preference/src/main/res/values-km/strings.xml
diff --git a/preference/preference/res/values-kn/strings.xml b/preference/preference/src/main/res/values-kn/strings.xml
similarity index 100%
rename from preference/preference/res/values-kn/strings.xml
rename to preference/preference/src/main/res/values-kn/strings.xml
diff --git a/preference/preference/res/values-ko/strings.xml b/preference/preference/src/main/res/values-ko/strings.xml
similarity index 100%
rename from preference/preference/res/values-ko/strings.xml
rename to preference/preference/src/main/res/values-ko/strings.xml
diff --git a/preference/preference/res/values-ky/strings.xml b/preference/preference/src/main/res/values-ky/strings.xml
similarity index 100%
rename from preference/preference/res/values-ky/strings.xml
rename to preference/preference/src/main/res/values-ky/strings.xml
diff --git a/preference/preference/res/values-lo/strings.xml b/preference/preference/src/main/res/values-lo/strings.xml
similarity index 100%
rename from preference/preference/res/values-lo/strings.xml
rename to preference/preference/src/main/res/values-lo/strings.xml
diff --git a/preference/preference/res/values-lt/strings.xml b/preference/preference/src/main/res/values-lt/strings.xml
similarity index 100%
rename from preference/preference/res/values-lt/strings.xml
rename to preference/preference/src/main/res/values-lt/strings.xml
diff --git a/preference/preference/res/values-lv/strings.xml b/preference/preference/src/main/res/values-lv/strings.xml
similarity index 100%
rename from preference/preference/res/values-lv/strings.xml
rename to preference/preference/src/main/res/values-lv/strings.xml
diff --git a/preference/preference/res/values-mk/strings.xml b/preference/preference/src/main/res/values-mk/strings.xml
similarity index 100%
rename from preference/preference/res/values-mk/strings.xml
rename to preference/preference/src/main/res/values-mk/strings.xml
diff --git a/preference/preference/res/values-ml/strings.xml b/preference/preference/src/main/res/values-ml/strings.xml
similarity index 100%
rename from preference/preference/res/values-ml/strings.xml
rename to preference/preference/src/main/res/values-ml/strings.xml
diff --git a/preference/preference/res/values-mn/strings.xml b/preference/preference/src/main/res/values-mn/strings.xml
similarity index 100%
rename from preference/preference/res/values-mn/strings.xml
rename to preference/preference/src/main/res/values-mn/strings.xml
diff --git a/preference/preference/res/values-mr/strings.xml b/preference/preference/src/main/res/values-mr/strings.xml
similarity index 100%
rename from preference/preference/res/values-mr/strings.xml
rename to preference/preference/src/main/res/values-mr/strings.xml
diff --git a/preference/preference/res/values-ms/strings.xml b/preference/preference/src/main/res/values-ms/strings.xml
similarity index 100%
rename from preference/preference/res/values-ms/strings.xml
rename to preference/preference/src/main/res/values-ms/strings.xml
diff --git a/preference/preference/res/values-my/strings.xml b/preference/preference/src/main/res/values-my/strings.xml
similarity index 100%
rename from preference/preference/res/values-my/strings.xml
rename to preference/preference/src/main/res/values-my/strings.xml
diff --git a/preference/preference/res/values-nb/strings.xml b/preference/preference/src/main/res/values-nb/strings.xml
similarity index 100%
rename from preference/preference/res/values-nb/strings.xml
rename to preference/preference/src/main/res/values-nb/strings.xml
diff --git a/preference/preference/res/values-ne/strings.xml b/preference/preference/src/main/res/values-ne/strings.xml
similarity index 100%
rename from preference/preference/res/values-ne/strings.xml
rename to preference/preference/src/main/res/values-ne/strings.xml
diff --git a/preference/preference/res/values-nl/strings.xml b/preference/preference/src/main/res/values-nl/strings.xml
similarity index 100%
rename from preference/preference/res/values-nl/strings.xml
rename to preference/preference/src/main/res/values-nl/strings.xml
diff --git a/preference/preference/res/values-or/strings.xml b/preference/preference/src/main/res/values-or/strings.xml
similarity index 100%
rename from preference/preference/res/values-or/strings.xml
rename to preference/preference/src/main/res/values-or/strings.xml
diff --git a/preference/preference/res/values-pa/strings.xml b/preference/preference/src/main/res/values-pa/strings.xml
similarity index 100%
rename from preference/preference/res/values-pa/strings.xml
rename to preference/preference/src/main/res/values-pa/strings.xml
diff --git a/preference/preference/res/values-pl/strings.xml b/preference/preference/src/main/res/values-pl/strings.xml
similarity index 100%
rename from preference/preference/res/values-pl/strings.xml
rename to preference/preference/src/main/res/values-pl/strings.xml
diff --git a/preference/preference/res/values-pt-rBR/strings.xml b/preference/preference/src/main/res/values-pt-rBR/strings.xml
similarity index 100%
rename from preference/preference/res/values-pt-rBR/strings.xml
rename to preference/preference/src/main/res/values-pt-rBR/strings.xml
diff --git a/preference/preference/res/values-pt-rPT/strings.xml b/preference/preference/src/main/res/values-pt-rPT/strings.xml
similarity index 100%
rename from preference/preference/res/values-pt-rPT/strings.xml
rename to preference/preference/src/main/res/values-pt-rPT/strings.xml
diff --git a/preference/preference/res/values-pt/strings.xml b/preference/preference/src/main/res/values-pt/strings.xml
similarity index 100%
rename from preference/preference/res/values-pt/strings.xml
rename to preference/preference/src/main/res/values-pt/strings.xml
diff --git a/preference/preference/res/values-ro/strings.xml b/preference/preference/src/main/res/values-ro/strings.xml
similarity index 100%
rename from preference/preference/res/values-ro/strings.xml
rename to preference/preference/src/main/res/values-ro/strings.xml
diff --git a/preference/preference/res/values-ru/strings.xml b/preference/preference/src/main/res/values-ru/strings.xml
similarity index 100%
rename from preference/preference/res/values-ru/strings.xml
rename to preference/preference/src/main/res/values-ru/strings.xml
diff --git a/preference/preference/res/values-si/strings.xml b/preference/preference/src/main/res/values-si/strings.xml
similarity index 100%
rename from preference/preference/res/values-si/strings.xml
rename to preference/preference/src/main/res/values-si/strings.xml
diff --git a/preference/preference/res/values-sk/strings.xml b/preference/preference/src/main/res/values-sk/strings.xml
similarity index 100%
rename from preference/preference/res/values-sk/strings.xml
rename to preference/preference/src/main/res/values-sk/strings.xml
diff --git a/preference/preference/res/values-sl/strings.xml b/preference/preference/src/main/res/values-sl/strings.xml
similarity index 100%
rename from preference/preference/res/values-sl/strings.xml
rename to preference/preference/src/main/res/values-sl/strings.xml
diff --git a/preference/preference/res/values-sq/strings.xml b/preference/preference/src/main/res/values-sq/strings.xml
similarity index 100%
rename from preference/preference/res/values-sq/strings.xml
rename to preference/preference/src/main/res/values-sq/strings.xml
diff --git a/preference/preference/res/values-sr/strings.xml b/preference/preference/src/main/res/values-sr/strings.xml
similarity index 100%
rename from preference/preference/res/values-sr/strings.xml
rename to preference/preference/src/main/res/values-sr/strings.xml
diff --git a/preference/preference/res/values-sv/strings.xml b/preference/preference/src/main/res/values-sv/strings.xml
similarity index 100%
rename from preference/preference/res/values-sv/strings.xml
rename to preference/preference/src/main/res/values-sv/strings.xml
diff --git a/preference/preference/res/values-sw/strings.xml b/preference/preference/src/main/res/values-sw/strings.xml
similarity index 100%
rename from preference/preference/res/values-sw/strings.xml
rename to preference/preference/src/main/res/values-sw/strings.xml
diff --git a/preference/preference/res/values-sw360dp/config.xml b/preference/preference/src/main/res/values-sw360dp/config.xml
similarity index 100%
rename from preference/preference/res/values-sw360dp/config.xml
rename to preference/preference/src/main/res/values-sw360dp/config.xml
diff --git a/preference/preference/res/values-ta/strings.xml b/preference/preference/src/main/res/values-ta/strings.xml
similarity index 100%
rename from preference/preference/res/values-ta/strings.xml
rename to preference/preference/src/main/res/values-ta/strings.xml
diff --git a/preference/preference/res/values-te/strings.xml b/preference/preference/src/main/res/values-te/strings.xml
similarity index 100%
rename from preference/preference/res/values-te/strings.xml
rename to preference/preference/src/main/res/values-te/strings.xml
diff --git a/preference/preference/res/values-th/strings.xml b/preference/preference/src/main/res/values-th/strings.xml
similarity index 100%
rename from preference/preference/res/values-th/strings.xml
rename to preference/preference/src/main/res/values-th/strings.xml
diff --git a/preference/preference/res/values-tl/strings.xml b/preference/preference/src/main/res/values-tl/strings.xml
similarity index 100%
rename from preference/preference/res/values-tl/strings.xml
rename to preference/preference/src/main/res/values-tl/strings.xml
diff --git a/preference/preference/res/values-tr/strings.xml b/preference/preference/src/main/res/values-tr/strings.xml
similarity index 100%
rename from preference/preference/res/values-tr/strings.xml
rename to preference/preference/src/main/res/values-tr/strings.xml
diff --git a/preference/preference/res/values-uk/strings.xml b/preference/preference/src/main/res/values-uk/strings.xml
similarity index 100%
rename from preference/preference/res/values-uk/strings.xml
rename to preference/preference/src/main/res/values-uk/strings.xml
diff --git a/preference/preference/res/values-ur/strings.xml b/preference/preference/src/main/res/values-ur/strings.xml
similarity index 100%
rename from preference/preference/res/values-ur/strings.xml
rename to preference/preference/src/main/res/values-ur/strings.xml
diff --git a/preference/preference/res/values-uz/strings.xml b/preference/preference/src/main/res/values-uz/strings.xml
similarity index 100%
rename from preference/preference/res/values-uz/strings.xml
rename to preference/preference/src/main/res/values-uz/strings.xml
diff --git a/preference/preference/res/values-v21/dimens.xml b/preference/preference/src/main/res/values-v21/dimens.xml
similarity index 100%
rename from preference/preference/res/values-v21/dimens.xml
rename to preference/preference/src/main/res/values-v21/dimens.xml
diff --git a/preference/preference/res/values-v21/styles.xml b/preference/preference/src/main/res/values-v21/styles.xml
similarity index 100%
rename from preference/preference/res/values-v21/styles.xml
rename to preference/preference/src/main/res/values-v21/styles.xml
diff --git a/preference/preference/res/values-v21/themes.xml b/preference/preference/src/main/res/values-v21/themes.xml
similarity index 100%
rename from preference/preference/res/values-v21/themes.xml
rename to preference/preference/src/main/res/values-v21/themes.xml
diff --git a/preference/preference/res/values-vi/strings.xml b/preference/preference/src/main/res/values-vi/strings.xml
similarity index 100%
rename from preference/preference/res/values-vi/strings.xml
rename to preference/preference/src/main/res/values-vi/strings.xml
diff --git a/preference/preference/res/values-zh-rCN/strings.xml b/preference/preference/src/main/res/values-zh-rCN/strings.xml
similarity index 100%
rename from preference/preference/res/values-zh-rCN/strings.xml
rename to preference/preference/src/main/res/values-zh-rCN/strings.xml
diff --git a/preference/preference/res/values-zh-rHK/strings.xml b/preference/preference/src/main/res/values-zh-rHK/strings.xml
similarity index 100%
rename from preference/preference/res/values-zh-rHK/strings.xml
rename to preference/preference/src/main/res/values-zh-rHK/strings.xml
diff --git a/preference/preference/res/values-zh-rTW/strings.xml b/preference/preference/src/main/res/values-zh-rTW/strings.xml
similarity index 100%
rename from preference/preference/res/values-zh-rTW/strings.xml
rename to preference/preference/src/main/res/values-zh-rTW/strings.xml
diff --git a/preference/preference/res/values-zu/strings.xml b/preference/preference/src/main/res/values-zu/strings.xml
similarity index 100%
rename from preference/preference/res/values-zu/strings.xml
rename to preference/preference/src/main/res/values-zu/strings.xml
diff --git a/preference/preference/res/values/attrs.xml b/preference/preference/src/main/res/values/attrs.xml
similarity index 100%
rename from preference/preference/res/values/attrs.xml
rename to preference/preference/src/main/res/values/attrs.xml
diff --git a/preference/preference/res/values/colors.xml b/preference/preference/src/main/res/values/colors.xml
similarity index 100%
rename from preference/preference/res/values/colors.xml
rename to preference/preference/src/main/res/values/colors.xml
diff --git a/preference/preference/res/values/config.xml b/preference/preference/src/main/res/values/config.xml
similarity index 100%
rename from preference/preference/res/values/config.xml
rename to preference/preference/src/main/res/values/config.xml
diff --git a/preference/preference/res/values/dimens.xml b/preference/preference/src/main/res/values/dimens.xml
similarity index 100%
rename from preference/preference/res/values/dimens.xml
rename to preference/preference/src/main/res/values/dimens.xml
diff --git a/preference/preference/res/values/ids.xml b/preference/preference/src/main/res/values/ids.xml
similarity index 100%
rename from preference/preference/res/values/ids.xml
rename to preference/preference/src/main/res/values/ids.xml
diff --git a/preference/preference/res/values/integers.xml b/preference/preference/src/main/res/values/integers.xml
similarity index 100%
rename from preference/preference/res/values/integers.xml
rename to preference/preference/src/main/res/values/integers.xml
diff --git a/preference/preference/res-public/values/public_attrs.xml b/preference/preference/src/main/res/values/public_attrs.xml
similarity index 100%
rename from preference/preference/res-public/values/public_attrs.xml
rename to preference/preference/src/main/res/values/public_attrs.xml
diff --git a/preference/preference/res-public/values/public_styles.xml b/preference/preference/src/main/res/values/public_styles.xml
similarity index 100%
rename from preference/preference/res-public/values/public_styles.xml
rename to preference/preference/src/main/res/values/public_styles.xml
diff --git a/preference/preference/res-public/values/public_themes.xml b/preference/preference/src/main/res/values/public_themes.xml
similarity index 100%
rename from preference/preference/res-public/values/public_themes.xml
rename to preference/preference/src/main/res/values/public_themes.xml
diff --git a/preference/preference/res/values/strings.xml b/preference/preference/src/main/res/values/strings.xml
similarity index 100%
rename from preference/preference/res/values/strings.xml
rename to preference/preference/src/main/res/values/strings.xml
diff --git a/preference/preference/res/values/styles.xml b/preference/preference/src/main/res/values/styles.xml
similarity index 100%
rename from preference/preference/res/values/styles.xml
rename to preference/preference/src/main/res/values/styles.xml
diff --git a/preference/preference/res/values/themes.xml b/preference/preference/src/main/res/values/themes.xml
similarity index 100%
rename from preference/preference/res/values/themes.xml
rename to preference/preference/src/main/res/values/themes.xml
diff --git a/privacysandbox/ads/ads-adservices-java/build.gradle b/privacysandbox/ads/ads-adservices-java/build.gradle
index 07ed384..56debed 100644
--- a/privacysandbox/ads/ads-adservices-java/build.gradle
+++ b/privacysandbox/ads/ads-adservices-java/build.gradle
@@ -38,11 +38,11 @@
     // To use CallbackToFutureAdapter
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     api(libs.guavaListenableFuture)
-    implementation project(":privacysandbox:ads:ads-adservices")
+    implementation(project(":privacysandbox:ads:ads-adservices"))
 
     androidTestImplementation(libs.kotlinCoroutinesAndroid)
-    androidTestImplementation project(":privacysandbox:ads:ads-adservices")
-    androidTestImplementation project(":javascriptengine:javascriptengine")
+    androidTestImplementation(project(":privacysandbox:ads:ads-adservices"))
+    androidTestImplementation(project(":javascriptengine:javascriptengine"))
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinTestJunit)
     androidTestImplementation(libs.mockitoKotlin4)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManager.kt
index df5782a..a6d6b6c 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManager.kt
@@ -49,7 +49,7 @@
          *   returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): AdIdManager? {
             return if (AdServicesInfo.adServicesVersion() >= 4) {
                 AdIdManagerApi33Ext4Impl(context)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi31Ext9Impl.kt
index 1e42cf8..27eaf51 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class AdIdManagerApi31Ext9Impl(context: Context) :
     AdIdManagerImplCommon(android.adservices.adid.AdIdManager.get(context))
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi33Ext4Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi33Ext4Impl.kt
index 77d2421..1466843 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi33Ext4Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerApi33Ext4Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 class AdIdManagerApi33Ext4Impl(context: Context) :
     AdIdManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerImplCommon.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerImplCommon.kt
index 94c2ad7..5e294d2 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerImplCommon.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adid/AdIdManagerImplCommon.kt
@@ -28,7 +28,7 @@
 import kotlinx.coroutines.suspendCancellableCoroutine
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("ClassVerificationFailure", "NewApi")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 open class AdIdManagerImplCommon(private val mAdIdManager: android.adservices.adid.AdIdManager) :
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionConfig.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionConfig.kt
index bec5c9a..4991c75 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionConfig.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionConfig.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.adselection
 
-import android.annotation.SuppressLint
 import android.net.Uri
 import android.os.Build
 import android.os.ext.SdkExtensions
@@ -47,7 +46,6 @@
  * @param trustedScoringSignalsUri URI endpoint of sell-side trusted signal from which creative
  *   specific realtime information can be fetched from.
  */
-@SuppressLint("ClassVerificationFailure")
 class AdSelectionConfig
 public constructor(
     val seller: AdTechIdentifier,
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManager.kt
index 7a3b053..6b8efc4 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManager.kt
@@ -283,7 +283,7 @@
          *   value returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): AdSelectionManager? {
             return if (AdServicesInfo.adServicesVersion() >= 4) {
                 AdSelectionManagerApi33Ext4Impl(context)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi31Ext9Impl.kt
index 54070d5..24f8a2e 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class AdSelectionManagerApi31Ext9Impl(context: Context) :
     AdSelectionManagerImplCommon(android.adservices.adselection.AdSelectionManager.get(context))
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi33Ext4Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi33Ext4Impl.kt
index babf6a3..e27b121 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi33Ext4Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerApi33Ext4Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 class AdSelectionManagerApi33Ext4Impl(context: Context) :
     AdSelectionManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerImplCommon.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerImplCommon.kt
index cd21ad8..2db68c6 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerImplCommon.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionManagerImplCommon.kt
@@ -31,7 +31,7 @@
 
 @OptIn(ExperimentalFeatures.Ext8OptIn::class, ExperimentalFeatures.Ext10OptIn::class)
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 open class AdSelectionManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionOutcome.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionOutcome.kt
index 4b437cb..59b102c 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionOutcome.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/AdSelectionOutcome.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.adselection
 
-import android.annotation.SuppressLint
 import android.net.Uri
 import android.os.Build
 import android.os.ext.SdkExtensions
@@ -33,7 +32,6 @@
  *   selection.
  * @param renderUri A render URL for the winning ad.
  */
-@SuppressLint("ClassVerificationFailure")
 class AdSelectionOutcome public constructor(val adSelectionId: Long, val renderUri: Uri) {
 
     /** Checks whether two [AdSelectionOutcome] objects contain the same information. */
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/ReportImpressionRequest.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/ReportImpressionRequest.kt
index ddae9b6..ad627dd 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/ReportImpressionRequest.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/adselection/ReportImpressionRequest.kt
@@ -36,7 +36,6 @@
  *   by [AdSelectionManager#getAdSelectionData} then the impression reporting request should only
  *   include the ad selection id.
  */
-@SuppressLint("ClassVerificationFailure")
 class ReportImpressionRequest
 public constructor(val adSelectionId: Long, val adSelectionConfig: AdSelectionConfig) {
     @ExperimentalFeatures.Ext8OptIn
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManager.kt
index 0b0e371..152cac9 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManager.kt
@@ -44,7 +44,7 @@
          *   returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): AppSetIdManager? {
             return if (AdServicesInfo.adServicesVersion() >= 4) {
                 AppSetIdManagerApi33Ext4Impl(context)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi31Ext9Impl.kt
index eeddd6d..ab76870 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class AppSetIdManagerApi31Ext9Impl(context: Context) :
     AppSetIdManagerImplCommon(android.adservices.appsetid.AppSetIdManager.get(context))
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi33Ext4Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi33Ext4Impl.kt
index 54983ca..c6ad21a 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi33Ext4Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerApi33Ext4Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 class AppSetIdManagerApi33Ext4Impl(context: Context) :
     AppSetIdManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerImplCommon.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerImplCommon.kt
index 0a422cc..dc38cc1 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerImplCommon.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/appsetid/AppSetIdManagerImplCommon.kt
@@ -26,7 +26,7 @@
 import kotlinx.coroutines.suspendCancellableCoroutine
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("ClassVerificationFailure", "NewApi")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 open class AppSetIdManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdData.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdData.kt
index 670608e..47f8c48 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdData.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdData.kt
@@ -35,7 +35,6 @@
  * @param adRenderId ad render id for server auctions
  */
 @OptIn(ExperimentalFeatures.Ext8OptIn::class, ExperimentalFeatures.Ext10OptIn::class)
-@SuppressLint("ClassVerificationFailure")
 class AdData
 @ExperimentalFeatures.Ext10OptIn
 public constructor(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdSelectionSignals.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdSelectionSignals.kt
index 5d67943..59503a0 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdSelectionSignals.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdSelectionSignals.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.common
 
-import android.annotation.SuppressLint
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.annotation.RequiresExtension
@@ -31,7 +30,6 @@
  *
  * @param signals Any valid JSON string to create the AdSelectionSignals with.
  */
-@SuppressLint("ClassVerificationFailure")
 class AdSelectionSignals public constructor(val signals: String) {
     /**
      * Compares this AdSelectionSignals to the specified object. The result is true if and only if
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
index 0273b1a..683a9bd 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.common
 
-import android.annotation.SuppressLint
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.annotation.RequiresExtension
@@ -27,7 +26,6 @@
  *
  * @param identifier The identifier.
  */
-@SuppressLint("ClassVerificationFailure")
 class AdTechIdentifier public constructor(val identifier: String) {
 
     /**
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManager.kt
index db1f91e..9f5a392 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManager.kt
@@ -125,7 +125,7 @@
          *   value returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): CustomAudienceManager? {
             return if (AdServicesInfo.adServicesVersion() >= 4) {
                 CustomAudienceManagerApi33Ext4Impl(context)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi31Ext9Impl.kt
index ee9ddd0..4c3ff46 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class CustomAudienceManagerApi31Ext9Impl(context: Context) :
     CustomAudienceManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi33Ext4Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi33Ext4Impl.kt
index 8ed1b45..924473c 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi33Ext4Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerApi33Ext4Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 class CustomAudienceManagerApi33Ext4Impl(context: Context) :
     CustomAudienceManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerImplCommon.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerImplCommon.kt
index 9aff642..c333164 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerImplCommon.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/customaudience/CustomAudienceManagerImplCommon.kt
@@ -32,7 +32,7 @@
 
 @OptIn(ExperimentalFeatures.Ext10OptIn::class)
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 open class CustomAudienceManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/DeletionRequest.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/DeletionRequest.kt
index 14782a9..7e005ae 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/DeletionRequest.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/DeletionRequest.kt
@@ -97,7 +97,7 @@
             "Start=$start, End=$end, DomainUris=$domainUris, OriginUris=$originUris }"
     }
 
-    @SuppressLint("ClassVerificationFailure", "NewApi")
+    @SuppressLint("NewApi")
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
     internal fun convertToAdServices(): android.adservices.measurement.DeletionRequest {
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManager.kt
index ea8584b..ca65337 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManager.kt
@@ -137,7 +137,7 @@
          *   value returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): MeasurementManager? {
             Log.d(
                 "MeasurementManager",
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi31Ext9Impl.kt
index 359681d..1ea2911 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class MeasurementManagerApi31Ext9Impl(context: Context) :
     MeasurementManagerImplCommon(android.adservices.measurement.MeasurementManager.get(context))
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi33Ext5Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi33Ext5Impl.kt
index f63061e..e7a052e 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi33Ext5Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerApi33Ext5Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 5)
 class MeasurementManagerApi33Ext5Impl(context: Context) :
     MeasurementManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerImplCommon.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerImplCommon.kt
index 2932b8e..1eaf91c 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerImplCommon.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/MeasurementManagerImplCommon.kt
@@ -33,7 +33,7 @@
 import kotlinx.coroutines.suspendCancellableCoroutine
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 5)
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 open class MeasurementManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceParams.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceParams.kt
index 1d3218f..0908a6a7 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceParams.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceParams.kt
@@ -51,7 +51,7 @@
     }
 
     internal companion object {
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
         @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
         internal fun convertWebSourceParams(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceRegistrationRequest.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceRegistrationRequest.kt
index 6499e73..8618b7c 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceRegistrationRequest.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebSourceRegistrationRequest.kt
@@ -92,7 +92,7 @@
         return "WebSourceRegistrationRequest { $vals }"
     }
 
-    @SuppressLint("ClassVerificationFailure", "NewApi")
+    @SuppressLint("NewApi")
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
     internal fun convertToAdServices():
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerParams.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerParams.kt
index bc3eefc..3d60473 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerParams.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerParams.kt
@@ -51,7 +51,7 @@
     }
 
     internal companion object {
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
         @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
         internal fun convertWebTriggerParams(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerRegistrationRequest.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerRegistrationRequest.kt
index 7384929..2993621 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerRegistrationRequest.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/measurement/WebTriggerRegistrationRequest.kt
@@ -49,7 +49,7 @@
             "Destination=$destination"
     }
 
-    @SuppressLint("ClassVerificationFailure", "NewApi")
+    @SuppressLint("NewApi")
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
     internal fun convertToAdServices():
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/signals/ProtectedSignalsManagerImpl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/signals/ProtectedSignalsManagerImpl.kt
index b69bc12..ec0da07 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/signals/ProtectedSignalsManagerImpl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/signals/ProtectedSignalsManagerImpl.kt
@@ -29,7 +29,7 @@
 
 @ExperimentalFeatures.Ext12OptIn
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 12)
 open class ProtectedSignalsManagerImpl(
     private val protectedSignalsManager: android.adservices.signals.ProtectedSignalsManager
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsRequestHelper.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsRequestHelper.kt
index a44b7ca..31091bd 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsRequestHelper.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsRequestHelper.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.topics
 
-import android.annotation.SuppressLint
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.annotation.RequiresExtension
@@ -24,7 +23,6 @@
 
 /** Helper class to consolidate conversion logic for GetTopicsRequest. */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("ClassVerificationFailure")
 object GetTopicsRequestHelper {
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 5)
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsResponseHelper.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsResponseHelper.kt
index 414c766..08bd019 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsResponseHelper.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/GetTopicsResponseHelper.kt
@@ -16,7 +16,6 @@
 
 package androidx.privacysandbox.ads.adservices.topics
 
-import android.annotation.SuppressLint
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.annotation.RequiresExtension
@@ -25,7 +24,6 @@
 
 /** Helper class to consolidate conversion logic for GetTopicsResponse. */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("ClassVerificationFailure")
 object GetTopicsResponseHelper {
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 4)
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManager.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManager.kt
index a1bbde3..91b77c9 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManager.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManager.kt
@@ -49,7 +49,7 @@
          *   value returned is null.
          */
         @JvmStatic
-        @SuppressLint("NewApi", "ClassVerificationFailure")
+        @SuppressLint("NewApi")
         fun obtain(context: Context): TopicsManager? {
             return if (AdServicesInfo.adServicesVersion() >= 11) {
                 TopicsManagerApi33Ext11Impl(context)
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext11Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext11Impl.kt
index 6352492..64f5f9f 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext11Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext11Impl.kt
@@ -24,7 +24,7 @@
 import androidx.privacysandbox.ads.adservices.common.ExperimentalFeatures
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 11)
 class TopicsManagerApi31Ext11Impl(context: Context) :
     TopicsManagerImplCommon(
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext9Impl.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext9Impl.kt
index cc73c22..70866366 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext9Impl.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/topics/TopicsManagerApi31Ext9Impl.kt
@@ -23,7 +23,7 @@
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-@SuppressLint("NewApi", "ClassVerificationFailure")
+@SuppressLint("NewApi")
 @RequiresExtension(extension = Build.VERSION_CODES.S, version = 9)
 class TopicsManagerApi31Ext9Impl(context: Context) :
     TopicsManagerImplCommon(
diff --git a/privacysandbox/sdkruntime/integration-tests/macrobenchmark/build.gradle b/privacysandbox/sdkruntime/integration-tests/macrobenchmark/build.gradle
new file mode 100644
index 0000000..4d06e65
--- /dev/null
+++ b/privacysandbox/sdkruntime/integration-tests/macrobenchmark/build.gradle
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024 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.
+ */
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.test")
+    id("kotlin-android")
+}
+android {
+    namespace = "androidx.privacysandbox.sdkruntime.integration.macrobenchmark"
+    targetProjectPath = ":privacysandbox:sdkruntime:integration-tests:testapp"
+    experimentalProperties["android.experimental.self-instrumenting"] = true
+    defaultConfig {
+        // :internal-testutils-macrobenchmark
+        minSdk = 23
+    }
+    privacySandbox {
+        enable = true
+    }
+}
+
+// Create a release build type and make sure it's the only one enabled.
+// This is needed because we benchmark the release build type only.
+android.buildTypes { release {} }
+androidComponents { beforeVariants(selector().all()) { enabled = buildType == "release" } }
+
+dependencies {
+    implementation(project(":benchmark:benchmark-junit4"))
+    implementation(project(":benchmark:benchmark-macro-junit4"))
+    implementation(project(":internal-testutils-macrobenchmark"))
+    implementation(libs.testRules)
+    implementation(libs.testExtJunit)
+    implementation(libs.testCore)
+    implementation(libs.testRunner)
+    implementation(libs.testUiautomator)
+}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/integration-tests/macrobenchmark/src/main/java/androidx/privacysandbox/sdkruntime/integration/macrobenchmark/SdkRuntimeBenchmark.kt b/privacysandbox/sdkruntime/integration-tests/macrobenchmark/src/main/java/androidx/privacysandbox/sdkruntime/integration/macrobenchmark/SdkRuntimeBenchmark.kt
new file mode 100644
index 0000000..b2aa064
--- /dev/null
+++ b/privacysandbox/sdkruntime/integration-tests/macrobenchmark/src/main/java/androidx/privacysandbox/sdkruntime/integration/macrobenchmark/SdkRuntimeBenchmark.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 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 androidx.privacysandbox.sdkruntime.integration.macrobenchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.testutils.measureStartup
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * ./gradlew :privacysandbox:sdkruntime:integration-tests:macrobenchmark:connectedReleaseAndroidTest
+ */
+@LargeTest
+@RunWith(Parameterized::class)
+class SdkRuntimeBenchmark(
+    @Suppress("unused") private val ciTestConfigType: String, // Added to test name by Parameterized
+) {
+    @get:Rule val benchmarkRule = MacrobenchmarkRule()
+
+    @Test
+    fun startup() =
+        benchmarkRule.measureStartup(
+            compilationMode = CompilationMode.DEFAULT,
+            startupMode = StartupMode.COLD,
+            packageName = "androidx.privacysandbox.sdkruntime.integration.testapp"
+        ) {
+            action = "androidx.privacysandbox.sdkruntime.integration.testapp.BenchmarkActivity"
+        }
+
+    companion object {
+        /** Add test config type (main or compat) to test name */
+        @Parameterized.Parameters(name = "{0}")
+        @JvmStatic
+        fun params(): List<String> =
+            listOf(
+                InstrumentationRegistry.getArguments()
+                    .getString("androidx.testConfigType", "LOCAL_RUN")
+            )
+    }
+}
diff --git a/privacysandbox/sdkruntime/integration-tests/testapp/build.gradle b/privacysandbox/sdkruntime/integration-tests/testapp/build.gradle
index 7334fd7..f7286b5 100644
--- a/privacysandbox/sdkruntime/integration-tests/testapp/build.gradle
+++ b/privacysandbox/sdkruntime/integration-tests/testapp/build.gradle
@@ -37,6 +37,9 @@
     implementation("androidx.appcompat:appcompat:1.6.0")
     implementation(project(":privacysandbox:sdkruntime:sdkruntime-client"))
 
+    // For macrobenchmarks on API 34+
+    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
+
     androidTestImplementation(project(":internal-testutils-runtime"))
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.testExtJunit)
diff --git a/privacysandbox/sdkruntime/integration-tests/testapp/src/main/AndroidManifest.xml b/privacysandbox/sdkruntime/integration-tests/testapp/src/main/AndroidManifest.xml
index 6072134..de84cc8 100644
--- a/privacysandbox/sdkruntime/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/privacysandbox/sdkruntime/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -31,5 +31,17 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
+        <activity
+            android:name=".BenchmarkActivity"
+            android:exported="true"
+            android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
+            android:theme="@style/Base.Theme.AppCompat">
+            <intent-filter>
+                <action
+                    android:name=
+                        "androidx.privacysandbox.sdkruntime.integration.testapp.BenchmarkActivity" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
     </application>
 </manifest>
diff --git a/privacysandbox/sdkruntime/integration-tests/testapp/src/main/java/androidx/privacysandbox/sdkruntime/integration/testapp/BenchmarkActivity.kt b/privacysandbox/sdkruntime/integration-tests/testapp/src/main/java/androidx/privacysandbox/sdkruntime/integration/testapp/BenchmarkActivity.kt
new file mode 100644
index 0000000..3664c46
--- /dev/null
+++ b/privacysandbox/sdkruntime/integration-tests/testapp/src/main/java/androidx/privacysandbox/sdkruntime/integration/testapp/BenchmarkActivity.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 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 androidx.privacysandbox.sdkruntime.integration.testapp
+
+import android.app.Activity
+import android.os.Bundle
+import kotlinx.coroutines.runBlocking
+
+class BenchmarkActivity : Activity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val api = TestAppApi(applicationContext)
+        val sdkApi = runBlocking { api.loadTestSdk() }
+        val invertResult = sdkApi.invert(false)
+        if (!invertResult) {
+            throw RuntimeException("Something went wrong")
+        }
+    }
+}
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/build.gradle b/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
index 4774389..f4961a2 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
+++ b/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
@@ -101,7 +101,7 @@
     api(libs.kotlinCoroutinesCore)
     implementation("androidx.core:core-ktx:1.15.0")
 
-    api project(":privacysandbox:sdkruntime:sdkruntime-core")
+    api(project(":privacysandbox:sdkruntime:sdkruntime-core"))
 
     implementation("androidx.core:core:1.15.0")
     implementation("androidx.activity:activity:1.9.3")
@@ -109,7 +109,7 @@
 
     testImplementation(libs.junit)
     testImplementation(libs.truth)
-    testImplementation project(":room:room-compiler-processing-testing")
+    testImplementation(project(":room:room-compiler-processing-testing"))
 
     // TODO(b/249982004): cleanup dependencies
     androidTestImplementation(libs.testCore)
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
index 7e5fdc6..57e7ede 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
@@ -59,7 +59,7 @@
         sandboxManagerCompat = SdkSandboxManagerCompat.from(context)
     }
 
-    @SuppressLint("NewApi", "ClassVerificationFailure") // For supporting DP Builds
+    @SuppressLint("NewApi") // For supporting DP Builds
     @After
     fun tearDown() {
         SdkSandboxManagerCompat.reset()
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/main/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompat.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/main/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompat.kt
index 187c0fe..fe3b785 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/main/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompat.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/main/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompat.kt
@@ -408,7 +408,7 @@
     }
 
     private object AppOwnedSdkRegistryFactory {
-        @SuppressLint("NewApi", "ClassVerificationFailure") // For supporting DP Builds
+        @SuppressLint("NewApi") // For supporting DP Builds
         fun create(context: Context): AppOwnedSdkRegistry {
             return if (
                 BuildCompat.AD_SERVICES_EXTENSION_INT >= 8 || AdServicesInfo.isDeveloperPreview()
diff --git a/privacysandbox/sdkruntime/sdkruntime-core/src/main/java/androidx/privacysandbox/sdkruntime/core/controller/impl/AppOwnedSdkProvider.kt b/privacysandbox/sdkruntime/sdkruntime-core/src/main/java/androidx/privacysandbox/sdkruntime/core/controller/impl/AppOwnedSdkProvider.kt
index dcd34f8..1051288 100644
--- a/privacysandbox/sdkruntime/sdkruntime-core/src/main/java/androidx/privacysandbox/sdkruntime/core/controller/impl/AppOwnedSdkProvider.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-core/src/main/java/androidx/privacysandbox/sdkruntime/core/controller/impl/AppOwnedSdkProvider.kt
@@ -46,7 +46,6 @@
     @RequiresExtension(extension = SdkExtensions.AD_SERVICES, version = 8)
     private class ApiAdServicesV8Impl(private val controller: SdkSandboxController) : ProviderImpl {
         @DoNotInline
-        @SuppressLint("ClassVerificationFailure") // flaky lint
         override fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat> {
             val apiResult = controller.getAppOwnedSdkSandboxInterfaces()
             return apiResult.map { AppOwnedSdkSandboxInterfaceCompat(it) }
@@ -54,7 +53,7 @@
     }
 
     companion object {
-        @SuppressLint("NewApi", "ClassVerificationFailure") // For supporting DP Builds
+        @SuppressLint("NewApi") // For supporting DP Builds
         fun create(controller: SdkSandboxController): AppOwnedSdkProvider {
             return if (
                 BuildCompat.AD_SERVICES_EXTENSION_INT >= 8 || AdServicesInfo.isDeveloperPreview()
diff --git a/privacysandbox/sdkruntime/sdkruntime-provider/build.gradle b/privacysandbox/sdkruntime/sdkruntime-provider/build.gradle
index 69c7a41..b4c525b 100644
--- a/privacysandbox/sdkruntime/sdkruntime-provider/build.gradle
+++ b/privacysandbox/sdkruntime/sdkruntime-provider/build.gradle
@@ -30,7 +30,7 @@
 }
 
 dependencies {
-    api project(":privacysandbox:sdkruntime:sdkruntime-core")
+    api(project(":privacysandbox:sdkruntime:sdkruntime-core"))
 
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testExtJunit)
diff --git a/privacysandbox/tools/integration-tests/testsdk-asb/build.gradle b/privacysandbox/tools/integration-tests/testsdk-asb/build.gradle
index bd5584c..fb19ea7 100644
--- a/privacysandbox/tools/integration-tests/testsdk-asb/build.gradle
+++ b/privacysandbox/tools/integration-tests/testsdk-asb/build.gradle
@@ -43,5 +43,5 @@
 }
 
 dependencies {
-    include project(":privacysandbox:tools:integration-tests:testsdk")
+    include(project(":privacysandbox:tools:integration-tests:testsdk"))
 }
diff --git a/privacysandbox/tools/tools-apicompiler/build.gradle b/privacysandbox/tools/tools-apicompiler/build.gradle
index f32bca4..52fb8f0 100644
--- a/privacysandbox/tools/tools-apicompiler/build.gradle
+++ b/privacysandbox/tools/tools-apicompiler/build.gradle
@@ -41,8 +41,8 @@
     api(libs.kotlinStdlib)
     implementation(libs.kspApi)
     implementation(libs.kotlinPoet)
-    implementation project(":privacysandbox:tools:tools")
-    implementation project(":privacysandbox:tools:tools-core")
+    implementation(project(":privacysandbox:tools:tools"))
+    implementation(project(":privacysandbox:tools:tools-core"))
 
     testImplementation(project(":privacysandbox:tools:tools-testing"))
     testImplementation(project(":room:room-compiler-processing-testing"))
diff --git a/privacysandbox/tools/tools-apigenerator/build.gradle b/privacysandbox/tools/tools-apigenerator/build.gradle
index 40370c9..6ea7548 100644
--- a/privacysandbox/tools/tools-apigenerator/build.gradle
+++ b/privacysandbox/tools/tools-apigenerator/build.gradle
@@ -80,8 +80,8 @@
         exclude group: "org.jetbrains.kotlin", module: "kotlin-stdlib"
     }
     implementation(libs.kotlinPoet)
-    implementation project(":privacysandbox:tools:tools")
-    implementation project(":privacysandbox:tools:tools-core")
+    implementation(project(":privacysandbox:tools:tools"))
+    implementation(project(":privacysandbox:tools:tools-core"))
 
     testImplementation(project(":internal-testutils-truth"))
     testImplementation(project(":privacysandbox:tools:tools-apipackager"))
diff --git a/privacysandbox/tools/tools-apipackager/build.gradle b/privacysandbox/tools/tools-apipackager/build.gradle
index fa48338..55cbcf2 100644
--- a/privacysandbox/tools/tools-apipackager/build.gradle
+++ b/privacysandbox/tools/tools-apipackager/build.gradle
@@ -38,8 +38,8 @@
         exclude group: "org.jetbrains.kotlin", module: "kotlin-stdlib"
     }
 
-    implementation project(":privacysandbox:tools:tools")
-    implementation project(":privacysandbox:tools:tools-core")
+    implementation(project(":privacysandbox:tools:tools"))
+    implementation(project(":privacysandbox:tools:tools-core"))
 
     testImplementation(project(":internal-testutils-truth"))
     testImplementation(project(":privacysandbox:tools:tools-testing"))
diff --git a/privacysandbox/tools/tools-core/build.gradle b/privacysandbox/tools/tools-core/build.gradle
index 908f915..69f7a93 100644
--- a/privacysandbox/tools/tools-core/build.gradle
+++ b/privacysandbox/tools/tools-core/build.gradle
@@ -41,7 +41,7 @@
     api(libs.protobuf)
     api(libs.kotlinPoet)
     implementation(libs.guava)
-    implementation project(":privacysandbox:tools:tools")
+    implementation(project(":privacysandbox:tools:tools"))
 
     testImplementation(libs.junit)
     testImplementation(libs.truth)
diff --git a/privacysandbox/tools/tools-testing/build.gradle b/privacysandbox/tools/tools-testing/build.gradle
index 1a20c8a..8281ce3 100644
--- a/privacysandbox/tools/tools-testing/build.gradle
+++ b/privacysandbox/tools/tools-testing/build.gradle
@@ -33,7 +33,7 @@
     api(libs.kspApi)
     api(libs.junit)
     api(libs.truth)
-    implementation project(":room:room-compiler-processing-testing")
+    implementation(project(":room:room-compiler-processing-testing"))
 }
 
 androidx {
diff --git a/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle b/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
index 1294960..26a8672 100644
--- a/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
+++ b/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
@@ -37,7 +37,6 @@
     api("androidx.annotation:annotation:1.8.1")
     implementation project(":privacysandbox:sdkruntime:sdkruntime-provider")
     implementation project(":privacysandbox:ui:integration-tests:testaidl")
-    implementation project(":privacysandbox:ui:ui-core")
     implementation project(":privacysandbox:ui:ui-provider")
     implementation project(":privacysandbox:ui:integration-tests:sdkproviderutils")
 }
diff --git a/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle b/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
index 0d6df29..254eef8 100644
--- a/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
+++ b/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
@@ -28,7 +28,6 @@
 dependencies {
     implementation "androidx.activity:activity-ktx:1.7.2"
     implementation project(":privacysandbox:ui:ui-client")
-    implementation project(":privacysandbox:ui:ui-core")
     implementation project(":privacysandbox:ui:ui-provider")
     implementation project(":privacysandbox:sdkruntime:sdkruntime-core")
     implementation project(":privacysandbox:activity:activity-core")
diff --git a/privacysandbox/ui/integration-tests/testaidl/src/main/aidl/androidx/privacysandbox/ui/integration/testaidl/ISdkApi.aidl b/privacysandbox/ui/integration-tests/testaidl/src/main/aidl/androidx/privacysandbox/ui/integration/testaidl/ISdkApi.aidl
deleted file mode 100644
index fe8f8b2..0000000
--- a/privacysandbox/ui/integration-tests/testaidl/src/main/aidl/androidx/privacysandbox/ui/integration/testaidl/ISdkApi.aidl
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright 2023 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 androidx.privacysandbox.ui.integration.testaidl;
-
-import android.os.Bundle;
-
-interface ISdkApi {
-   Bundle loadBannerAd(int adType, int sdkType, boolean withSlowDraw, boolean drawViewability);
-   void requestResize(int width, int height);
-   oneway void triggerProcessDeath();
-   oneway void launchFullscreenAd(in Bundle launcherInfo, int screenOrientation, int backButtonNavigation);
-}
diff --git a/privacysandbox/ui/integration-tests/testapp/build.gradle b/privacysandbox/ui/integration-tests/testapp/build.gradle
index f461c92..2ff14c0 100644
--- a/privacysandbox/ui/integration-tests/testapp/build.gradle
+++ b/privacysandbox/ui/integration-tests/testapp/build.gradle
@@ -55,14 +55,13 @@
     implementation("androidx.compose.foundation:foundation-layout:1.7.5")
     implementation("androidx.compose.material3:material3-android:1.3.1")
     implementation("androidx.compose.ui:ui-util:1.7.5")
-    implementation project(':privacysandbox:activity:activity-core')
-    implementation project(':privacysandbox:activity:activity-client')
+    implementation(project(":privacysandbox:activity:activity-core"))
+    implementation(project(":privacysandbox:activity:activity-client"))
     implementation(project(":privacysandbox:sdkruntime:sdkruntime-client"))
     implementation(project(":privacysandbox:ui:integration-tests:testaidl"))
     implementation(project(":privacysandbox:ui:integration-tests:sdkproviderutils"))
     implementation(project(":privacysandbox:ui:integration-tests:testsdkproviderwrapper"))
     implementation(project(":privacysandbox:ui:integration-tests:mediateesdkproviderwrapper"))
-    implementation(project(":privacysandbox:ui:ui-core"))
     implementation(project(":privacysandbox:ui:ui-client"))
     implementation(project(":privacysandbox:ui:ui-provider"))
 }
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
index 19f1db2..56e84a8 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
@@ -30,7 +30,8 @@
 import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
 import androidx.privacysandbox.ui.integration.sdkproviderutils.SdkApiConstants.Companion.AdType
 import androidx.privacysandbox.ui.integration.sdkproviderutils.SdkApiConstants.Companion.MediationOption
-import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
+import androidx.privacysandbox.ui.integration.testsdkprovider.ISdkApi
+import androidx.privacysandbox.ui.integration.testsdkprovider.ISdkApiFactory
 import kotlinx.coroutines.runBlocking
 
 /**
@@ -57,7 +58,10 @@
                 loadedSdk = sdkSandboxManager.loadSdk(SDK_NAME, Bundle())
                 sdkSandboxManager.loadSdk(MEDIATEE_SDK_NAME, Bundle())
             }
-            sdkApi = ISdkApi.Stub.asInterface(loadedSdk.getInterface())
+            sdkApi =
+                ISdkApiFactory.wrapToISdkApi(
+                    checkNotNull(loadedSdk.getInterface()) { "Cannot find Sdk Service!" }
+                )
         }
     }
 
@@ -105,9 +109,11 @@
         drawViewabilityLayer: Boolean,
         waitInsideOnDraw: Boolean = false
     ) {
-        val sdkBundle =
-            sdkApi.loadBannerAd(adType, mediationOption, waitInsideOnDraw, drawViewabilityLayer)
-        sandboxedSdkView.setAdapter(SandboxedUiAdapterFactory.createFromCoreLibInfo(sdkBundle))
+        runBlocking {
+            val sdkBundle =
+                sdkApi.loadBannerAd(adType, mediationOption, waitInsideOnDraw, drawViewabilityLayer)
+            sandboxedSdkView.setAdapter(SandboxedUiAdapterFactory.createFromCoreLibInfo(sdkBundle))
+        }
     }
 
     open fun handleDrawerStateChange(isDrawerOpen: Boolean) {
diff --git a/privacysandbox/ui/integration-tests/testingutils/build.gradle b/privacysandbox/ui/integration-tests/testingutils/build.gradle
index 52c7232..fefa781 100644
--- a/privacysandbox/ui/integration-tests/testingutils/build.gradle
+++ b/privacysandbox/ui/integration-tests/testingutils/build.gradle
@@ -26,5 +26,5 @@
 }
 
 dependencies {
-    implementation project(":privacysandbox:ui:ui-client")
+    implementation(project(":privacysandbox:ui:ui-client"))
 }
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle b/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
index b366e9a..b2f5d51 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
@@ -16,7 +16,7 @@
 
 plugins {
     id("AndroidXPlugin")
-    id("com.android.library")
+    id("androidx.privacysandboxplugin")
     id("org.jetbrains.kotlin.android")
 }
 
@@ -35,10 +35,17 @@
 dependencies {
     api(libs.kotlinStdlib)
     api("androidx.annotation:annotation:1.8.1")
-    implementation project(":privacysandbox:sdkruntime:sdkruntime-provider")
-    implementation project(":privacysandbox:ui:integration-tests:testaidl")
-    implementation project(":privacysandbox:ui:integration-tests:sdkproviderutils")
-    implementation project(":privacysandbox:ui:ui-core")
-    implementation project(":privacysandbox:ui:ui-provider")
-    implementation project(":webkit:webkit")
+    implementation(project(":privacysandbox:sdkruntime:sdkruntime-provider"))
+    implementation(project(":privacysandbox:ui:integration-tests:testaidl"))
+    implementation(project(":privacysandbox:ui:integration-tests:sdkproviderutils"))
+    implementation(project(":privacysandbox:ui:ui-provider"))
+    implementation(project(":webkit:webkit"))
+
+    // Add library dependencies at head - to override the dependencies that will be added by
+    // privacysandboxplugin.
+    implementation(project(':privacysandbox:tools:tools'))
+    ksp(project(':privacysandbox:tools:tools-apicompiler'))
+    implementation(project(":privacysandbox:ui:ui-core"))
+    implementation(project(':privacysandbox:activity:activity-core'))
+    implementation(project(':privacysandbox:activity:activity-provider'))
 }
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/ISdkApi.kt b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/ISdkApi.kt
new file mode 100644
index 0000000..02419f2
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/ISdkApi.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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 androidx.privacysandbox.ui.integration.testsdkprovider
+
+import android.os.Bundle
+import androidx.privacysandbox.tools.PrivacySandboxService
+
+@PrivacySandboxService
+interface ISdkApi {
+    suspend fun loadBannerAd(
+        adType: Int,
+        mediationOption: Int,
+        waitInsideOnDraw: Boolean,
+        drawViewability: Boolean
+    ): Bundle
+
+    fun requestResize(width: Int, height: Int)
+
+    fun triggerProcessDeath()
+
+    fun launchFullscreenAd(launcherInfo: Bundle, screenOrientation: Int, backButtonNavigation: Int)
+}
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
index 7c103f7..7cc4c81 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
@@ -35,16 +35,15 @@
 import androidx.privacysandbox.ui.integration.sdkproviderutils.ViewabilityHandler
 import androidx.privacysandbox.ui.integration.sdkproviderutils.fullscreen.FullscreenAd
 import androidx.privacysandbox.ui.integration.testaidl.IMediateeSdkApi
-import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
 import androidx.privacysandbox.ui.provider.toCoreLibInfo
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
 
-class SdkApi(private val sdkContext: Context) : ISdkApi.Stub() {
+class SdkApi(private val sdkContext: Context) : ISdkApi {
     private val testAdapters = TestAdapters(sdkContext)
     private val handler = Handler(Looper.getMainLooper())
 
-    override fun loadBannerAd(
+    override suspend fun loadBannerAd(
         @AdType adType: Int,
         @MediationOption mediationOption: Int,
         waitInsideOnDraw: Boolean,
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkProviderImpl.kt b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkProviderImpl.kt
index 62b9086..caac113a 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkProviderImpl.kt
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkProviderImpl.kt
@@ -17,17 +17,7 @@
 package androidx.privacysandbox.ui.integration.testsdkprovider
 
 import android.content.Context
-import android.os.Bundle
-import android.view.View
-import androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
-import androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
 
-class SdkProviderImpl : SandboxedSdkProviderCompat() {
-    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
-        return SandboxedSdkCompat(SdkApi(context!!))
-    }
-
-    override fun getView(windowContext: Context, params: Bundle, width: Int, height: Int): View {
-        TODO("Not yet implemented")
-    }
+class SdkProviderImpl : AbstractSandboxedSdkProviderCompat() {
+    override fun createISdkApi(context: Context): ISdkApi = SdkApi(context)
 }
diff --git a/privacysandbox/ui/ui-client/build.gradle b/privacysandbox/ui/ui-client/build.gradle
index 54e3e13..1f05fe9 100644
--- a/privacysandbox/ui/ui-client/build.gradle
+++ b/privacysandbox/ui/ui-client/build.gradle
@@ -32,13 +32,11 @@
 dependencies {
     api(libs.kotlinStdlib)
     api("androidx.annotation:annotation:1.8.1")
-
-    implementation("androidx.core:core:1.12.0")
-
-    implementation("androidx.lifecycle:lifecycle-common:2.6.2")
-    implementation("androidx.privacysandbox.sdkruntime:sdkruntime-client:1.0.0-alpha14")
-    implementation("androidx.customview:customview-poolingcontainer:1.0.0")
-    implementation(project(":privacysandbox:ui:ui-core"))
+    api("androidx.core:core:1.12.0")
+    api("androidx.lifecycle:lifecycle-common:2.6.2")
+    api("androidx.privacysandbox.sdkruntime:sdkruntime-client:1.0.0-alpha14")
+    api("androidx.customview:customview-poolingcontainer:1.0.0")
+    api(project(":privacysandbox:ui:ui-core"))
 
     androidTestImplementation(project(":internal-testutils-runtime"))
     androidTestImplementation(libs.junit)
@@ -52,9 +50,9 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.testUiautomator)
-    androidTestImplementation project(":appcompat:appcompat")
-    androidTestImplementation project(":privacysandbox:ui:ui-provider")
-    androidTestImplementation project(":privacysandbox:ui:integration-tests:testingutils")
+    androidTestImplementation(project(":appcompat:appcompat"))
+    androidTestImplementation(project(":privacysandbox:ui:ui-provider"))
+    androidTestImplementation(project(":privacysandbox:ui:integration-tests:testingutils"))
 }
 
 android {
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
index 54bc440..2225374 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
@@ -391,7 +391,6 @@
                 }
             }
 
-            @SuppressLint("ClassVerificationFailure")
             override fun notifyResized(width: Int, height: Int) {
 
                 val parentView = surfaceView.parent as View
diff --git a/privacysandbox/ui/ui-core/build.gradle b/privacysandbox/ui/ui-core/build.gradle
index bbb6d03..f451589 100644
--- a/privacysandbox/ui/ui-core/build.gradle
+++ b/privacysandbox/ui/ui-core/build.gradle
@@ -31,8 +31,9 @@
 
 dependencies {
     api(libs.kotlinStdlib)
-    implementation(libs.kotlinCoroutinesCore)
+    api(libs.kotlinCoroutinesCore)
     api("androidx.annotation:annotation:1.8.1")
+    api("androidx.core:core:1.13.0")
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(libs.testExtJunit)
@@ -40,7 +41,6 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
-    implementation("androidx.core:core:1.13.0")
 }
 
 android {
diff --git a/privacysandbox/ui/ui-provider/build.gradle b/privacysandbox/ui/ui-provider/build.gradle
index 67c11c6..0ebe484 100644
--- a/privacysandbox/ui/ui-provider/build.gradle
+++ b/privacysandbox/ui/ui-provider/build.gradle
@@ -31,11 +31,10 @@
 
 dependencies {
     api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
     api("androidx.annotation:annotation:1.8.1")
-
-    implementation project(":privacysandbox:ui:ui-core")
-    implementation("androidx.core:core:1.12.0")
-    implementation(libs.kotlinCoroutinesCore)
+    api("androidx.core:core:1.12.0")
+    api project(":privacysandbox:ui:ui-core")
 
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/privacysandbox/ui/ui-tests/build.gradle b/privacysandbox/ui/ui-tests/build.gradle
index a51f5f0..8fa1016 100644
--- a/privacysandbox/ui/ui-tests/build.gradle
+++ b/privacysandbox/ui/ui-tests/build.gradle
@@ -30,10 +30,9 @@
 }
 
 dependencies {
-    implementation project(":privacysandbox:ui:ui-core")
+    implementation(libs.kotlinStdlib)
     implementation project(":privacysandbox:ui:ui-client")
     implementation project(":privacysandbox:ui:ui-provider")
-    implementation(libs.kotlinStdlib)
     implementation("androidx.recyclerview:recyclerview:1.3.2")
 
     androidTestImplementation(project(":internal-testutils-runtime"))
@@ -45,9 +44,9 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.truth)
-    androidTestImplementation project(":appcompat:appcompat")
-    androidTestImplementation project(":recyclerview:recyclerview")
-    androidTestImplementation project(":privacysandbox:ui:integration-tests:testingutils")
+    androidTestImplementation(project(":appcompat:appcompat"))
+    androidTestImplementation(project(":recyclerview:recyclerview"))
+    androidTestImplementation(project(":privacysandbox:ui:integration-tests:testingutils"))
 }
 
 android {
diff --git a/profileinstaller/profileinstaller-benchmark/build.gradle b/profileinstaller/profileinstaller-benchmark/build.gradle
index 7dfe226..78d904e 100644
--- a/profileinstaller/profileinstaller-benchmark/build.gradle
+++ b/profileinstaller/profileinstaller-benchmark/build.gradle
@@ -40,7 +40,7 @@
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-runtime")
+    androidTestImplementation(project(":internal-testutils-runtime"))
     androidTestImplementation(libs.kotlinStdlib)
 }
 
diff --git a/recyclerview/recyclerview/build.gradle b/recyclerview/recyclerview/build.gradle
index ba5cd36..0b5fd9d 100644
--- a/recyclerview/recyclerview/build.gradle
+++ b/recyclerview/recyclerview/build.gradle
@@ -47,14 +47,9 @@
 }
 
 android {
-    sourceSets {
-        main.res.srcDirs "res", "res-public"
-    }
-
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
     }
-
     defaultConfig {
         compileSdk = 35
         testInstrumentationRunner "androidx.recyclerview.test.TestRunner"
diff --git a/recyclerview/recyclerview/res/values/attrs.xml b/recyclerview/recyclerview/src/main/res/values/attrs.xml
similarity index 100%
rename from recyclerview/recyclerview/res/values/attrs.xml
rename to recyclerview/recyclerview/src/main/res/values/attrs.xml
diff --git a/recyclerview/recyclerview/res/values/dimens.xml b/recyclerview/recyclerview/src/main/res/values/dimens.xml
similarity index 100%
rename from recyclerview/recyclerview/res/values/dimens.xml
rename to recyclerview/recyclerview/src/main/res/values/dimens.xml
diff --git a/recyclerview/recyclerview/res/values/ids.xml b/recyclerview/recyclerview/src/main/res/values/ids.xml
similarity index 100%
rename from recyclerview/recyclerview/res/values/ids.xml
rename to recyclerview/recyclerview/src/main/res/values/ids.xml
diff --git a/recyclerview/recyclerview/res-public/values/public_attrs.xml b/recyclerview/recyclerview/src/main/res/values/public_attrs.xml
similarity index 100%
rename from recyclerview/recyclerview/res-public/values/public_attrs.xml
rename to recyclerview/recyclerview/src/main/res/values/public_attrs.xml
diff --git a/room/benchmark/build.gradle b/room/benchmark/build.gradle
index a65a1a2..8613005 100644
--- a/room/benchmark/build.gradle
+++ b/room/benchmark/build.gradle
@@ -34,7 +34,7 @@
 dependencies {
     androidTestImplementation(project(":room:room-common"))
     androidTestImplementation(project(":room:room-runtime"))
-    kspAndroidTest project(":room:room-compiler")
+    kspAndroidTest(project(":room:room-compiler"))
     androidTestImplementation(project(":room:room-rxjava2"))
     androidTestImplementation("androidx.arch.core:core-runtime:2.2.0")
     androidTestImplementation(projectOrArtifact(":benchmark:benchmark-junit4"))
diff --git a/room/integration-tests/autovaluetestapp/build.gradle b/room/integration-tests/autovaluetestapp/build.gradle
index 04f09c0..2e220c6 100644
--- a/room/integration-tests/autovaluetestapp/build.gradle
+++ b/room/integration-tests/autovaluetestapp/build.gradle
@@ -24,7 +24,7 @@
     implementation(project(":room:room-runtime"))
     implementation("androidx.arch.core:core-runtime:2.2.0")
 
-    androidTestAnnotationProcessor project(":room:room-compiler")
+    androidTestAnnotationProcessor(project(":room:room-compiler"))
     androidTestAnnotationProcessor(libs.autoValue)
     androidTestAnnotationProcessor(libs.autoValueParcel)
 
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
index d067381..86bd96e 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
@@ -16,29 +16,39 @@
 
 package androidx.room.integration.kotlintestapp.test
 
+import android.content.Context
 import androidx.kruth.assertThat
+import androidx.room.ExperimentalRoomApi
+import androidx.room.Room
+import androidx.room.integration.kotlintestapp.TestDatabase
 import androidx.room.integration.kotlintestapp.vo.Book
 import androidx.room.integration.kotlintestapp.vo.Playlist
 import androidx.room.integration.kotlintestapp.vo.PlaylistSongXRef
 import androidx.room.integration.kotlintestapp.vo.PlaylistWithSongs
 import androidx.room.integration.kotlintestapp.vo.Song
 import androidx.room.withTransaction
+import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
+import kotlin.coroutines.cancellation.CancellationException
 import kotlin.test.Ignore
+import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.async
 import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.buffer
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.produceIn
 import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.joinAll
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
 import kotlinx.coroutines.yield
 import org.junit.After
 import org.junit.Assert.fail
@@ -387,4 +397,46 @@
 
         job.cancelAndJoin()
     }
+
+    @Test
+    fun collectBooks_autoClose(): Unit = runBlocking {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        context.deleteDatabase("auto-close-test.db")
+
+        @OptIn(ExperimentalRoomApi::class)
+        val database =
+            Room.databaseBuilder<TestDatabase>(context, "auto-close-test.db")
+                .setAutoCloseTimeout(1, TimeUnit.SECONDS)
+                .build()
+
+        database.booksDao().insertPublisher(TestUtil.PUBLISHER.publisherId, TestUtil.PUBLISHER.name)
+
+        val collectJob = launch {
+            var collections = 0
+            database.booksDao().getBooksFlow().collect {
+                when (collections) {
+                    0 -> {
+                        assertThat(it).isEmpty()
+                    }
+                    1 -> {
+                        assertThat(it).containsExactly(TestUtil.BOOK_1)
+                        throw CancellationException()
+                    }
+                    else -> {
+                        fail("Received too many Flow.collect")
+                    }
+                }
+                collections++
+            }
+        }
+
+        val insertJob = launch {
+            delay(2.seconds)
+            database.booksDao().insertBookSuspend(TestUtil.BOOK_1)
+        }
+
+        withTimeout(TimeUnit.SECONDS.toMillis(3)) { listOf(collectJob, insertJob).joinAll() }
+
+        database.close()
+    }
 }
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/UserDao.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/UserDao.java
index fcb0a14..83214d1 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/UserDao.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/UserDao.java
@@ -271,6 +271,9 @@
     @Query("SELECT * FROM user where mAge > :age")
     public abstract DataSource.Factory<Integer, User> loadPagedByAge(int age);
 
+    @Query("SELECT * FROM user where mLastName = :name")
+    public abstract DataSource.Factory<Integer, User> loadPagedByLastname(String name);
+
     @RawQuery(observedEntities = User.class)
     public abstract DataSource.Factory<Integer, User> loadPagedByAgeWithObserver(
             SupportSQLiteQuery query);
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/javac/ElementExt.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/javac/ElementExt.kt
index 4fe0f34..513fd43 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/javac/ElementExt.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/javac/ElementExt.kt
@@ -21,26 +21,39 @@
 import com.google.auto.common.MoreTypes
 import com.squareup.javapoet.ParameterizedTypeName
 import com.squareup.javapoet.TypeName
+import javax.lang.model.element.AnnotationMirror
 import javax.lang.model.element.Element
 import javax.lang.model.element.ExecutableElement
 import javax.lang.model.element.Modifier
 import javax.lang.model.element.TypeElement
 import javax.lang.model.type.ExecutableType
+import javax.lang.model.type.TypeMirror
 import javax.lang.model.util.Types
 import kotlin.coroutines.Continuation
 
 private val NONNULL_ANNOTATIONS =
-    arrayOf("androidx.annotation.NonNull", "org.jetbrains.annotations.NotNull")
+    arrayOf(
+        "androidx.annotation.NonNull",
+        "org.jetbrains.annotations.NotNull",
+        "org.jspecify.annotations.NonNull"
+    )
 
 private val NULLABLE_ANNOTATIONS =
-    arrayOf("androidx.annotation.Nullable", "org.jetbrains.annotations.Nullable")
+    arrayOf(
+        "androidx.annotation.Nullable",
+        "org.jetbrains.annotations.Nullable",
+        "org.jspecify.annotations.Nullable"
+    )
+
+/** Checks if any of the [annotations] are present on the [Element] or its [type]. */
+private fun Element.hasAnyOf(annotations: Array<String>, type: TypeMirror) =
+    annotationMirrors.hasAnyOf(annotations) || type.annotationMirrors.hasAnyOf(annotations)
 
 @Suppress("UnstableApiUsage")
-private fun Element.hasAnyOf(annotations: Array<String>) =
-    annotationMirrors.any { annotationMirror ->
-        val annotationTypeElement = MoreElements.asType(annotationMirror.annotationType.asElement())
-        annotations.any { annotationTypeElement.qualifiedName.contentEquals(it) }
-    }
+private fun List<AnnotationMirror>.hasAnyOf(annotations: Array<String>) = any { annotationMirror ->
+    val annotationTypeElement = MoreElements.asType(annotationMirror.annotationType.asElement())
+    annotations.any { annotationTypeElement.qualifiedName.contentEquals(it) }
+}
 
 internal val Element.nullability: XNullability
     get() {
@@ -53,9 +66,9 @@
                     else -> it
                 }
             }
-        return if (asType.kind.isPrimitive || hasAnyOf(NONNULL_ANNOTATIONS)) {
+        return if (asType.kind.isPrimitive || hasAnyOf(NONNULL_ANNOTATIONS, asType)) {
             XNullability.NONNULL
-        } else if (hasAnyOf(NULLABLE_ANNOTATIONS)) {
+        } else if (hasAnyOf(NULLABLE_ANNOTATIONS, asType)) {
             XNullability.NULLABLE
         } else {
             XNullability.UNKNOWN
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XNullabilityTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XNullabilityTest.kt
index 17ba896..3f1da51 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XNullabilityTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XNullabilityTest.kt
@@ -412,6 +412,69 @@
         }
     }
 
+    @Test
+    fun nullability_from_jspecify() {
+        val src =
+            Source.java(
+                "foo.bar.Foo",
+                """
+                    package foo.bar;
+                    import org.jspecify.annotations.NonNull;
+                    import org.jspecify.annotations.Nullable;
+                    class Foo {
+                        public @NonNull String nonNullField = "";
+                        public @NonNull String returnsNonNull() {
+                            return "";
+                        }
+                        public String @Nullable [] returnsNullableArray() {
+                            return null;
+                        }
+                        public void hasNullableParam(@Nullable String param) {}
+                    }
+                """
+                    .trimIndent()
+            )
+        val nonNullSrc =
+            Source.java(
+                "NonNull",
+                """
+                    package org.jspecify.annotations;
+                    import java.lang.annotation.ElementType;
+                    import java.lang.annotation.Target;
+                    @Target(ElementType.TYPE_USE)
+                    public @interface NonNull {}
+                """
+                    .trimIndent()
+            )
+        val nullableSrc =
+            Source.java(
+                "Nullable",
+                """
+                    package org.jspecify.annotations;
+                    import java.lang.annotation.ElementType;
+                    import java.lang.annotation.Target;
+                    @Target(ElementType.TYPE_USE)
+                    public @interface Nullable {}
+                """
+                    .trimIndent()
+            )
+        runProcessorTestWithoutKsp(sources = listOf(src, nonNullSrc, nullableSrc)) { invocation ->
+            val element = invocation.processingEnv.requireTypeElement("foo.bar.Foo")
+            element.getField("nonNullField").let { field ->
+                assertThat(field.type.nullability).isEqualTo(NONNULL)
+            }
+            element.getMethodByJvmName("returnsNonNull").let { method ->
+                assertThat(method.returnType.nullability).isEqualTo(NONNULL)
+            }
+            element.getMethodByJvmName("returnsNullableArray").let { method ->
+                assertThat(method.returnType.nullability).isEqualTo(NULLABLE)
+            }
+            element.getMethodByJvmName("hasNullableParam").getParameter("param").let { param ->
+                assertThat(param.type.nullability).isEqualTo(NULLABLE)
+            }
+        }
+    }
+
     companion object {
         val PRIMITIVE_JTYPE_NAMES =
             listOf(
diff --git a/room/room-runtime/api/restricted_current.txt b/room/room-runtime/api/restricted_current.txt
index df44066..bb0324f 100644
--- a/room/room-runtime/api/restricted_current.txt
+++ b/room/room-runtime/api/restricted_current.txt
@@ -377,6 +377,7 @@
     method public void bindLong(int index, long value);
     method public void bindNull(int index);
     method public void bindString(int index, String value);
+    method public void bindText(int index, String value);
     method public void bindTo(androidx.sqlite.db.SupportSQLiteProgram statement);
     method public void bindTo(androidx.sqlite.SQLiteStatement statement);
     method public void clearBindings();
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
index a423a69..e88a1cd 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
@@ -129,6 +129,8 @@
         stringBindings[index] = value
     }
 
+    fun bindText(index: Int, value: String) = bindString(index, value)
+
     override fun bindBlob(index: Int, value: ByteArray) {
         bindingTypes[index] = BLOB
         blobBindings[index] = value
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/driver/SupportSQLiteConnectionPool.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/driver/SupportSQLiteConnectionPool.android.kt
index 194d4047..f795a9d 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/driver/SupportSQLiteConnectionPool.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/driver/SupportSQLiteConnectionPool.android.kt
@@ -30,10 +30,10 @@
  */
 internal class SupportSQLiteConnectionPool(internal val supportDriver: SupportSQLiteDriver) :
     ConnectionPool {
-    private val supportConnection by
-        lazy(LazyThreadSafetyMode.PUBLICATION) {
+    private val supportConnection: SupportSQLitePooledConnection
+        get() {
             val fileName = supportDriver.openHelper.databaseName ?: ":memory:"
-            SupportSQLitePooledConnection(supportDriver.open(fileName))
+            return SupportSQLitePooledConnection(supportDriver.open(fileName))
         }
 
     override suspend fun <R> useConnection(
diff --git a/security/security-state-provider/build.gradle b/security/security-state-provider/build.gradle
index 2524d32..e941c91 100644
--- a/security/security-state-provider/build.gradle
+++ b/security/security-state-provider/build.gradle
@@ -32,7 +32,7 @@
 
 dependencies {
     implementation(libs.kotlinSerializationJson)
-    implementation project(":security:security-state")
+    implementation(project(":security:security-state"))
 
     testImplementation(libs.testExtJunit)
     testImplementation(libs.mockitoKotlin4)
diff --git a/settings.gradle b/settings.gradle
index 7bdea24..4d3a6fa 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -188,13 +188,35 @@
     return null
 }
 
-private String getRequestedProjectPrefix() {
-    def envProp = providers.environmentVariable("PROJECT_PREFIX")
-    if (envProp.isPresent()) {
-        return envProp.get()
+/**
+ * Utility class to handle PROJECT_PREFIX environment variable.
+ */
+class ProjectPrefixFilter {
+    // list of projects parsed from the PROJECT_PREFIX env environmentVariable
+    private final List<String> projectPrefixes
+    // set to true if the environment variable is present
+    final boolean isConfigured
+    ProjectPrefixFilter(providers) {
+        def envProp = providers.environmentVariable("PROJECT_PREFIX")
+        if (envProp.isPresent()) {
+            isConfigured = true
+            def value = envProp.get()
+            projectPrefixes = value?.split(',')?.collect { it.trim() } ?: []
+        } else {
+            isConfigured = false
+            projectPrefixes = []
+        }
     }
-    return null
+
+
+    boolean matches(String name) {
+        return projectPrefixes.any { prefix ->
+            name.startsWith(prefix)
+        }
+    }
 }
+ext.projectPrefixFilter = new ProjectPrefixFilter(providers)
+
 
 boolean isAllProjects() {
     return requestedProjectSubsetName == null || requestedProjectSubsetName == "ALL"
@@ -313,9 +335,13 @@
 //   the Maven artifactId
 //
 def includeProject(String name, filePath, List<BuildType> filter = []) {
-    if (getRequestedProjectPrefix() != null) {
-        if (name.startsWith(getRequestedProjectPrefix())) filteredProjects.add(name)
-    } else if (shouldIncludeForFilter(filter)) filteredProjects.add(name)
+    if (projectPrefixFilter.isConfigured) {
+        if (projectPrefixFilter.matches(name)) {
+            filteredProjects.add(name)
+        }
+    } else if (shouldIncludeForFilter(filter)) {
+        filteredProjects.add(name)
+    }
     def file
     if (filePath != null) {
         if (filePath instanceof String) {
@@ -395,6 +421,7 @@
 includeProject(":benchmark:benchmark-macro", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":benchmark:benchmark-macro-junit4", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":benchmark:benchmark-samples", "benchmark/samples", [BuildType.MAIN, BuildType.COMPOSE])
+includeProject(":benchmark:benchmark-traceprocessor", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":benchmark:integration-tests:baselineprofile-producer", [BuildType.MAIN])
 includeProject(":benchmark:integration-tests:baselineprofile-consumer", [BuildType.MAIN])
 includeProject(":benchmark:integration-tests:baselineprofile-flavors-producer", [BuildType.MAIN])
@@ -428,7 +455,6 @@
 includeProject(":camera:camera-compose:camera-compose-samples", "camera/camera-compose/samples", [BuildType.CAMERA])
 includeProject(":camera:camera-core", [BuildType.CAMERA])
 includeProject(":camera:camera-effects", [BuildType.CAMERA])
-includeProject(":camera:camera-effects-still-portrait", [BuildType.CAMERA])
 includeProject(":camera:camera-extensions", [BuildType.CAMERA])
 includeProject(":camera:camera-extensions-stub", [BuildType.CAMERA])
 includeProject(":camera:camera-feature-combination-query", [BuildType.CAMERA])
@@ -650,7 +676,6 @@
 includeProject(":credentials:credentials-samples", "credentials/credentials/samples", [BuildType.MAIN])
 includeProject(":credentials:credentials-fido", [BuildType.MAIN])
 includeProject(":credentials:credentials-play-services-auth", [BuildType.MAIN])
-includeProject(":credentials:credentials-provider", [BuildType.MAIN])
 includeProject(":credentials:credentials-e2ee", [BuildType.MAIN])
 includeProject(":credentials:credentials-play-services-e2ee", [BuildType.MAIN])
 includeProject(":credentials:registry:registry-digitalcredentials-mdoc", [BuildType.MAIN])
@@ -845,7 +870,6 @@
 includeProject(":navigation:navigation-runtime", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
 includeProject(":navigation:navigation-runtime-lint", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
 includeProject(":navigation:navigation-runtime-ktx", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
-includeProject(":navigation:navigation-runtime-truth", [BuildType.MAIN, BuildType.FLAN])
 includeProject(":navigation:navigation-safe-args-generator", [BuildType.MAIN, BuildType.FLAN])
 includeProject(":navigation:navigation-safe-args-gradle-plugin", [BuildType.MAIN, BuildType.FLAN])
 includeProject(":navigation:navigation-testing", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
@@ -886,6 +910,7 @@
 includeProject(":privacysandbox:ads:ads-adservices-java", [BuildType.MAIN])
 includeProject(":privacysandbox:plugins:plugins-privacysandbox-library", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:integration-tests:testaidl", [BuildType.MAIN])
+includeProject(":privacysandbox:sdkruntime:integration-tests:macrobenchmark", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:integration-tests:testapp", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:integration-tests:testsdk", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:integration-tests:testsdk-asb", [BuildType.MAIN])
@@ -1129,7 +1154,6 @@
 includeProject(":window:window-testing", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.WINDOW])
 includeProject(":work:integration-tests:testapp", [BuildType.MAIN])
 includeProject(":work:work-benchmark", [BuildType.MAIN])
-includeProject(":work:work-datatransfer", [BuildType.MAIN])
 includeProject(":work:work-gcm", [BuildType.MAIN])
 includeProject(":work:work-inspection", [BuildType.MAIN])
 includeProject(":work:work-multiprocess", [BuildType.MAIN])
@@ -1139,18 +1163,17 @@
 includeProject(":work:work-rxjava2", [BuildType.MAIN])
 includeProject(":work:work-rxjava3", [BuildType.MAIN])
 includeProject(":work:work-testing", [BuildType.MAIN])
-
-/////////////////////////////
-//
-// Optional projects
-//
-/////////////////////////////
-
-File repoRoot = new File(rootDir, "../..").canonicalFile
-
-includeOptionalProject(":xr:xr", new File(repoRoot, "xr/xr"), [BuildType.XR])
-includeOptionalProject(":xr:integration-tests:compose-adaptive-sample", new File(repoRoot, "xr/integration-tests/compose-adaptive-sample"), [BuildType.XR])
-includeOptionalProject(":xr:xr-material3-adaptive", new File(repoRoot, "xr/xr-material3-adaptive"), [BuildType.XR])
+includeProject(":xr:arcore:arcore", [BuildType.XR])
+includeProject(":xr:compose:compose", [BuildType.XR])
+includeProject(":xr:compose:compose-testing", [BuildType.XR])
+includeProject(":xr:compose:material3:material3", [BuildType.XR])
+includeProject(":xr:compose:material3:integration-tests:testapp", [BuildType.XR])
+includeProject(":xr:runtime:runtime", [BuildType.XR])
+includeProject(":xr:runtime:runtime-openxr", [BuildType.XR])
+includeProject(":xr:runtime:runtime-testing", [BuildType.XR])
+includeProject(":xr:scenecore:scenecore", [BuildType.XR])
+includeProject(":xr:scenecore:scenecore-testing", [BuildType.XR])
+includeProject(":xr:xr-stubs", [BuildType.XR])
 
 /////////////////////////////
 //
diff --git a/slice/slice-remotecallback/build.gradle b/slice/slice-remotecallback/build.gradle
index 6e12825..02cb09a 100644
--- a/slice/slice-remotecallback/build.gradle
+++ b/slice/slice-remotecallback/build.gradle
@@ -40,7 +40,7 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestAnnotationProcessor project(":remotecallback:remotecallback-processor")
+    androidTestAnnotationProcessor(project(":remotecallback:remotecallback-processor"))
 }
 
 androidx {
diff --git a/sqlite/sqlite-bundled/build.gradle b/sqlite/sqlite-bundled/build.gradle
index 9fbba67..ae6ed6b 100644
--- a/sqlite/sqlite-bundled/build.gradle
+++ b/sqlite/sqlite-bundled/build.gradle
@@ -249,5 +249,4 @@
     type = LibraryType.PUBLISHED_LIBRARY
     inceptionYear = "2023"
     description = "The implementation of SQLite library using the bundled SQLite."
-    metalavaK2UastEnabled = false
 }
diff --git a/test/uiautomator/integration-tests/testapp/build.gradle b/test/uiautomator/integration-tests/testapp/build.gradle
index 3f4be03..27a3777 100644
--- a/test/uiautomator/integration-tests/testapp/build.gradle
+++ b/test/uiautomator/integration-tests/testapp/build.gradle
@@ -27,16 +27,16 @@
     implementation(libs.androidx.annotation)
     implementation("androidx.core:core:1.6.0")
 
-    implementation project(":activity:activity")
+    implementation(project(":activity:activity"))
     implementation(project(":activity:activity-compose"))
-    implementation project(":compose:foundation:foundation")
+    implementation(project(":compose:foundation:foundation"))
     implementation(project(":compose:foundation:foundation-layout"))
     implementation(project(":compose:material:material"))
     implementation(project(":compose:runtime:runtime"))
     implementation(project(":compose:ui:ui"))
-    implementation project(":compose:ui:ui-graphics")
-    implementation project(":compose:ui:ui-text")
-    implementation project(":compose:ui:ui-unit")
+    implementation(project(":compose:ui:ui-graphics"))
+    implementation(project(":compose:ui:ui-text"))
+    implementation(project(":compose:ui:ui-unit"))
 
     androidTestImplementation(project(":test:uiautomator:uiautomator"))
     androidTestImplementation(libs.testCore)
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
deleted file mode 100644
index 871d662..0000000
--- a/tv/tv-foundation/build.gradle
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright (C) 2022 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.
- */
-
-/**
- * This file was created using the `create_project.py` script located in the
- * `<AndroidX root>/development/project-creator` directory.
- *
- * Please use that script when creating a new project, rather than copying an existing project and
- * modifying its settings.
- */
-import androidx.build.LibraryType
-
-plugins {
-    id("AndroidXPlugin")
-    id("AndroidXComposePlugin")
-    id("com.android.library")
-    id("org.jetbrains.kotlin.android")
-}
-
-dependencies {
-    api(libs.kotlinStdlib)
-
-    def composeVersion = "1.6.8"
-
-    api("androidx.annotation:annotation:1.8.1")
-    api("androidx.compose.animation:animation:$composeVersion")
-    api("androidx.compose.foundation:foundation:$composeVersion")
-    api("androidx.compose.foundation:foundation-layout:$composeVersion")
-    api("androidx.compose.runtime:runtime:$composeVersion")
-    api("androidx.compose.ui:ui-util:$composeVersion")
-    api("androidx.compose.ui:ui:$composeVersion")
-    api("androidx.compose.ui:ui-graphics:$composeVersion")
-    api("androidx.compose.ui:ui-text:$composeVersion")
-
-    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
-
-    androidTestImplementation(libs.truth)
-    androidTestImplementation(project(":compose:runtime:runtime"))
-    androidTestImplementation(project(":compose:ui:ui-test"))
-    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
-    androidTestImplementation(project(":compose:test-utils"))
-    androidTestImplementation(libs.testRunner)
-}
-
-android {
-    compileSdk = 35
-    namespace = "androidx.tv.foundation"
-}
-
-androidx {
-    name = "TV Foundation"
-    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
-    mavenVersion = LibraryVersions.TV
-    inceptionYear = "2022"
-    description = "This library makes it easier for developers" +
-            "to write Jetpack Compose applications for TV devices by providing " +
-            "functionality to support TV specific devices sizes, shapes and d-pad navigation " +
-            "supported components. It builds upon the Jetpack Compose libraries."
-    legacyDisableKotlinStrictApiMode = true
-    metalavaK2UastEnabled = false
-}
diff --git a/tv/tv-foundation/build.gradle.kts b/tv/tv-foundation/build.gradle.kts
new file mode 100644
index 0000000..b9bfdd7
--- /dev/null
+++ b/tv/tv-foundation/build.gradle.kts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2022 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api("androidx.annotation:annotation:1.8.1")
+
+    val composeVersion = "1.6.8"
+    api("androidx.compose.animation:animation:$composeVersion")
+    api("androidx.compose.foundation:foundation:$composeVersion")
+    api("androidx.compose.foundation:foundation-layout:$composeVersion")
+    api("androidx.compose.runtime:runtime:$composeVersion")
+    api("androidx.compose.ui:ui-util:$composeVersion")
+    api("androidx.compose.ui:ui:$composeVersion")
+    api("androidx.compose.ui:ui-graphics:$composeVersion")
+    api("androidx.compose.ui:ui-text:$composeVersion")
+
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
+
+    androidTestImplementation(libs.truth)
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-test"))
+    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+    androidTestImplementation(project(":compose:test-utils"))
+    androidTestImplementation(libs.testRunner)
+}
+
+android {
+    compileSdk = 35
+    namespace = "androidx.tv.foundation"
+}
+
+androidx {
+    name = "TV Foundation"
+    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
+    mavenVersion = LibraryVersions["TV"]
+    inceptionYear = "2022"
+    description = "This library makes it easier for developers" +
+            "to write Jetpack Compose applications for TV devices by providing " +
+            "functionality to support TV specific devices sizes, shapes and d-pad navigation " +
+            "supported components. It builds upon the Jetpack Compose libraries."
+    legacyDisableKotlinStrictApiMode = true
+}
diff --git a/tv/tv-material/build.gradle b/tv/tv-material/build.gradle
deleted file mode 100644
index a4d04af..0000000
--- a/tv/tv-material/build.gradle
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (C) 2022 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.
- */
-
-/**
- * This file was created using the `create_project.py` script located in the
- * `<AndroidX root>/development/project-creator` directory.
- *
- * Please use that script when creating a new project, rather than copying an existing project and
- * modifying its settings.
- */
-import androidx.build.LibraryType
-
-plugins {
-    id("AndroidXPlugin")
-    id("AndroidXComposePlugin")
-    id("com.android.library")
-    id("org.jetbrains.kotlin.android")
-}
-
-dependencies {
-    api(libs.kotlinStdlib)
-
-    def annotationVersion = "1.8.0"
-    def composeVersion = "1.6.8"
-    def profileInstallerVersion = "1.4.0"
-
-    api("androidx.annotation:annotation:1.8.1")
-    api("androidx.compose.animation:animation:$composeVersion")
-    api("androidx.compose.foundation:foundation:$composeVersion")
-    api("androidx.compose.foundation:foundation-layout:$composeVersion")
-    api("androidx.compose.runtime:runtime:$composeVersion")
-    api("androidx.compose.material:material-icons-core:$composeVersion")
-    api("androidx.compose.ui:ui-util:$composeVersion")
-    api("androidx.compose.ui:ui:$composeVersion")
-    api("androidx.compose.ui:ui-graphics:$composeVersion")
-    api("androidx.compose.ui:ui-text:$composeVersion")
-
-    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
-
-    androidTestImplementation(libs.truth)
-    androidTestImplementation(project(":compose:runtime:runtime"))
-    androidTestImplementation(project(":compose:ui:ui-test"))
-    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
-    androidTestImplementation(project(":compose:test-utils"))
-    androidTestImplementation(project(":test:screenshot:screenshot"))
-    androidTestImplementation(project(":tv:tv-foundation"))
-    androidTestImplementation(libs.testRunner)
-}
-
-android {
-    compileSdk = 35
-    namespace = "androidx.tv.material"
-    sourceSets.androidTest.assets.srcDirs +=
-             project.rootDir.absolutePath + "/../../golden/tv/compose/material3"
-}
-
-androidx {
-    name = "TV Material"
-    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
-    mavenVersion = LibraryVersions.TV_MATERIAL
-    inceptionYear = "2022"
-    description = "build TV applications using controls that adhere to Material Design Language."
-    legacyDisableKotlinStrictApiMode = true
-    metalavaK2UastEnabled = false
-    samples(project(":tv:tv-material-samples"))
-}
diff --git a/tv/tv-material/build.gradle.kts b/tv/tv-material/build.gradle.kts
new file mode 100644
index 0000000..78a44d3
--- /dev/null
+++ b/tv/tv-material/build.gradle.kts
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+
+    api("androidx.annotation:annotation:1.8.1")
+
+    val composeVersion = "1.6.8"
+    api("androidx.compose.animation:animation:$composeVersion")
+    api("androidx.compose.foundation:foundation:$composeVersion")
+    api("androidx.compose.foundation:foundation-layout:$composeVersion")
+    api("androidx.compose.runtime:runtime:$composeVersion")
+    api("androidx.compose.material:material-icons-core:$composeVersion")
+    api("androidx.compose.ui:ui-util:$composeVersion")
+    api("androidx.compose.ui:ui:$composeVersion")
+    api("androidx.compose.ui:ui-graphics:$composeVersion")
+    api("androidx.compose.ui:ui-text:$composeVersion")
+
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
+
+    androidTestImplementation(libs.truth)
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-test"))
+    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+    androidTestImplementation(project(":compose:test-utils"))
+    androidTestImplementation(project(":test:screenshot:screenshot"))
+    androidTestImplementation(project(":tv:tv-foundation"))
+    androidTestImplementation(libs.testRunner)
+}
+
+android {
+    compileSdk = 35
+    namespace = "androidx.tv.material"
+}
+
+androidx {
+    name = "TV Material"
+    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
+    mavenVersion = LibraryVersions["TV_MATERIAL"]
+    inceptionYear = "2022"
+    description = "build TV applications using controls that adhere to Material Design Language."
+    legacyDisableKotlinStrictApiMode = true
+    metalavaK2UastEnabled = false
+    samples(project(":tv:tv-material-samples"))
+    addGoldenImageAssets()
+}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/GoldenCommon.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/GoldenCommon.kt
index ebb26c0..4ffce1d 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/GoldenCommon.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/GoldenCommon.kt
@@ -16,4 +16,4 @@
 
 package androidx.tv.material3
 
-internal const val TV_GOLDEN_MATERIAL3 = "tv/compose/material3"
+internal const val TV_GOLDEN_MATERIAL3 = "tv/tv-material"
diff --git a/versionedparcelable/versionedparcelable/build.gradle b/versionedparcelable/versionedparcelable/build.gradle
index 83adae3..5ad222b 100644
--- a/versionedparcelable/versionedparcelable/build.gradle
+++ b/versionedparcelable/versionedparcelable/build.gradle
@@ -40,7 +40,7 @@
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
     androidTestImplementation(libs.truth)
-    androidTestAnnotationProcessor project(":versionedparcelable:versionedparcelable-compiler")
+    androidTestAnnotationProcessor(project(":versionedparcelable:versionedparcelable-compiler"))
 }
 
 android {
diff --git a/viewpager/viewpager/build.gradle b/viewpager/viewpager/build.gradle
index a9e7a6b..cbfe218 100644
--- a/viewpager/viewpager/build.gradle
+++ b/viewpager/viewpager/build.gradle
@@ -24,7 +24,7 @@
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.mockitoCore)
     androidTestImplementation(libs.dexmakerMockito)
-    androidTestImplementation project(":internal-testutils-espresso")
+    androidTestImplementation(project(":internal-testutils-espresso"))
 }
 
 androidx {
diff --git a/viewpager2/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java b/viewpager2/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
index f2a17f1..5cc3b59 100644
--- a/viewpager2/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
+++ b/viewpager2/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
@@ -175,7 +175,6 @@
     }
 
     @RequiresApi(21)
-    @SuppressLint("ClassVerificationFailure")
     public ViewPager2(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
             int defStyleRes) {
         super(context, attrs, defStyleAttr, defStyleRes);
@@ -390,7 +389,6 @@
         Parcelable mAdapterState;
 
         @RequiresApi(24)
-        @SuppressLint("ClassVerificationFailure")
         SavedState(Parcel source, ClassLoader loader) {
             super(source, loader);
             readValues(source, loader);
diff --git a/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle b/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle
index 7db4232..4acae42 100644
--- a/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle
+++ b/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle
@@ -39,7 +39,7 @@
 dependencies {
     implementation(libs.kotlinStdlib)
     implementation(libs.constraintLayout)
-    implementation project(":activity:activity-ktx")
+    implementation(project(":activity:activity-ktx"))
     implementation("androidx.core:core-ktx")
     implementation(libs.material)
     implementation(project(":profileinstaller:profileinstaller"))
diff --git a/wear/compose/compose-foundation/benchmark/build.gradle b/wear/compose/compose-foundation/benchmark/build.gradle
index 2cdab7f..b69e6d1 100644
--- a/wear/compose/compose-foundation/benchmark/build.gradle
+++ b/wear/compose/compose-foundation/benchmark/build.gradle
@@ -45,13 +45,13 @@
 
 dependencies {
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:ui:ui-text:ui-text-benchmark")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":wear:compose:compose-foundation")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-text:ui-text-benchmark"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":wear:compose:compose-foundation"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/wear/compose/compose-foundation/build.gradle b/wear/compose/compose-foundation/build.gradle
index a3c1ff9..104ffff 100644
--- a/wear/compose/compose-foundation/build.gradle
+++ b/wear/compose/compose-foundation/build.gradle
@@ -78,9 +78,6 @@
     }
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/wear/compose/foundation"
-
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
     }
@@ -98,4 +95,5 @@
     samples(project(":wear:compose:compose-foundation-samples"))
     legacyDisableKotlinStrictApiMode = true
     metalavaK2UastEnabled = false
+    addGoldenImageAssets()
 }
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/CurvedScreenshotTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/CurvedScreenshotTest.kt
index 3360d61..2f7c7b2 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/CurvedScreenshotTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/CurvedScreenshotTest.kt
@@ -309,4 +309,4 @@
     }
 }
 
-internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/foundation"
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/compose-foundation"
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
index 28bce2c..96cac7a 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
@@ -62,7 +62,7 @@
     hapticsEnabled: Boolean
 ): RotaryHapticHandler =
     if (hapticsEnabled) {
-        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.VANILLA_ICE_CREAM)
             rememberCustomRotaryHapticHandler(scrollableState)
         else rememberPlatformRotaryHapticHandler(scrollableState)
     } else rememberDisabledRotaryHapticHandler()
@@ -171,7 +171,7 @@
  * @param hapticsChannel Channel to which haptic events will be sent
  * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
  */
-private class CustomRotaryHapticHandler(
+internal class CustomRotaryHapticHandler(
     private val scrollableState: ScrollableState,
     private val hapticsChannel: Channel<RotaryHapticsType>,
     private val hapticsThresholdPx: Long = 50
diff --git a/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
index ed52c17..2700d9d 100644
--- a/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
+++ b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
@@ -18,8 +18,11 @@
 
 import android.R
 import android.app.Activity
+import android.os.Build
 import android.provider.Settings
 import android.view.View
+import androidx.compose.foundation.gestures.rememberScrollableState
+import androidx.compose.ui.test.junit4.createComposeRule
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.delay
@@ -29,6 +32,7 @@
 import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertEquals
 import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -127,6 +131,9 @@
 
 @RunWith(RobolectricTestRunner::class)
 class HapticsTest {
+
+    @get:Rule val rule = createComposeRule()
+
     @Test
     @Config(sdk = [33])
     fun testPixelWatch1Wear4() {
@@ -211,6 +218,17 @@
         assertEquals(HapticConstants.GalaxyWatchConstants, getHapticConstants())
     }
 
+    @Test
+    @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM, Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
+    fun testCustomHapticsHandler() {
+        var rotaryHapticsHandler: RotaryHapticHandler? = null
+        rule.setContent {
+            val scrollableState = rememberScrollableState { 0f }
+            rotaryHapticsHandler = rememberRotaryHapticHandler(scrollableState, true)
+        }
+        assertEquals(rotaryHapticsHandler?.javaClass, CustomRotaryHapticHandler::class.java)
+    }
+
     private fun getHapticConstants(): HapticConstants {
         val activity = Robolectric.buildActivity(Activity::class.java).get()
         val view = activity.findViewById<View>(R.id.content)
diff --git a/wear/compose/compose-material-core/build.gradle b/wear/compose/compose-material-core/build.gradle
index bc7615d..849fbd2 100644
--- a/wear/compose/compose-material-core/build.gradle
+++ b/wear/compose/compose-material-core/build.gradle
@@ -48,8 +48,6 @@
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
     androidTestImplementation(project(":compose:test-utils"))
-
-    androidTestImplementation(project(":test:screenshot:screenshot"))
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.truth)
 
@@ -66,8 +64,6 @@
     }
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/wear/compose/materialcore"
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
     }
diff --git a/wear/compose/compose-material/benchmark/build.gradle b/wear/compose/compose-material/benchmark/build.gradle
index 77ff797..7d71235 100644
--- a/wear/compose/compose-material/benchmark/build.gradle
+++ b/wear/compose/compose-material/benchmark/build.gradle
@@ -45,15 +45,15 @@
 
 dependencies {
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:ui:ui-text:ui-text-benchmark")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:material:material")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":wear:compose:compose-foundation")
-    androidTestImplementation project(":wear:compose:compose-material")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-text:ui-text-benchmark"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:material:material"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":wear:compose:compose-foundation"))
+    androidTestImplementation(project(":wear:compose:compose-material"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/wear/compose/compose-material/build.gradle b/wear/compose/compose-material/build.gradle
index d09f201..4393637 100644
--- a/wear/compose/compose-material/build.gradle
+++ b/wear/compose/compose-material/build.gradle
@@ -68,15 +68,10 @@
     }
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/wear/compose/material"
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
     }
     namespace = "androidx.wear.compose.material"
-    lint {
-        baseline = file("lint-baseline.xml")
-    }
 }
 
 androidx {
@@ -90,4 +85,5 @@
     legacyDisableKotlinStrictApiMode = true
     metalavaK2UastEnabled = false
     samples(project(":wear:compose:compose-material-samples"))
+    addGoldenImageAssets()
 }
diff --git a/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/Screenshot.kt b/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/Screenshot.kt
index 53370ef..732c9f6 100644
--- a/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/Screenshot.kt
+++ b/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/Screenshot.kt
@@ -16,4 +16,4 @@
 
 package androidx.wear.compose.material
 
-internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/material"
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/compose-material"
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index f8be644..fc1325b 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -317,7 +317,7 @@
   public final class CircularProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize);
     method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
-    method @androidx.compose.runtime.Composable public static void CircularProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicatorStatic(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled, optional float overflowColorAlphaFraction, optional kotlin.jvm.functions.Function0<java.lang.Float>? targetProgress);
   }
 
   @androidx.compose.runtime.Immutable @androidx.compose.runtime.Stable public final class ColorScheme {
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index f8be644..fc1325b 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -317,7 +317,7 @@
   public final class CircularProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize);
     method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
-    method @androidx.compose.runtime.Composable public static void CircularProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicatorStatic(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled, optional float overflowColorAlphaFraction, optional kotlin.jvm.functions.Function0<java.lang.Float>? targetProgress);
   }
 
   @androidx.compose.runtime.Immutable @androidx.compose.runtime.Stable public final class ColorScheme {
diff --git a/wear/compose/compose-material3/benchmark/build.gradle b/wear/compose/compose-material3/benchmark/build.gradle
index ae72baf..9e88dd9 100644
--- a/wear/compose/compose-material3/benchmark/build.gradle
+++ b/wear/compose/compose-material3/benchmark/build.gradle
@@ -45,14 +45,14 @@
 
 dependencies {
 
-    androidTestImplementation project(":benchmark:benchmark-junit4")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:ui:ui-text:ui-text-benchmark")
-    androidTestImplementation project(":compose:foundation:foundation")
-    androidTestImplementation project(":compose:runtime:runtime")
-    androidTestImplementation project(":compose:benchmark-utils")
-    androidTestImplementation project(":wear:compose:compose-foundation")
-    androidTestImplementation project(":wear:compose:compose-material3")
+    androidTestImplementation(project(":benchmark:benchmark-junit4"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:ui:ui-text:ui-text-benchmark"))
+    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation(project(":compose:runtime:runtime"))
+    androidTestImplementation(project(":compose:benchmark-utils"))
+    androidTestImplementation(project(":wear:compose:compose-foundation"))
+    androidTestImplementation(project(":wear:compose:compose-material3"))
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.junit)
     androidTestImplementation(libs.kotlinStdlib)
diff --git a/wear/compose/compose-material3/build.gradle b/wear/compose/compose-material3/build.gradle
index efbb530a..923e7bf 100644
--- a/wear/compose/compose-material3/build.gradle
+++ b/wear/compose/compose-material3/build.gradle
@@ -76,15 +76,10 @@
     }
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/wear/compose/material3"
     buildTypes.configureEach {
         consumerProguardFiles("proguard-rules.pro")
     }
     namespace = "androidx.wear.compose.material3"
-    lint {
-        baseline = file("lint-baseline.xml")
-    }
 }
 
 androidx {
@@ -100,6 +95,7 @@
     metalavaK2UastEnabled = false
     samples(project(":wear:compose:compose-material3-samples"))
     kotlinTarget = KotlinTarget.KOTLIN_1_9
+    addGoldenImageAssets()
 }
 
 tasks.withType(KotlinCompile).configureEach {
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
index f1b4d1f6..8add3dd 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
@@ -58,7 +58,7 @@
 import androidx.wear.compose.material3.StepperDefaults
 import androidx.wear.compose.material3.SwitchButton
 import androidx.wear.compose.material3.Text
-import androidx.wear.compose.material3.samples.CircularProgressIndicatorContentSample
+import androidx.wear.compose.material3.samples.CircularProgressIndicatorStaticSample
 import androidx.wear.compose.material3.samples.FullScreenProgressIndicatorSample
 import androidx.wear.compose.material3.samples.IndeterminateProgressArcSample
 import androidx.wear.compose.material3.samples.IndeterminateProgressIndicatorSample
@@ -118,7 +118,7 @@
                     Centralize { CircularProgressCustomisableFullScreenDemo() }
                 },
                 ComposableDemo("Custom animation") {
-                    Centralize { CircularProgressIndicatorContentSample() }
+                    Centralize { CircularProgressIndicatorStaticSample() }
                 },
             )
         ),
diff --git a/wear/compose/compose-material3/macrobenchmark-common/build.gradle b/wear/compose/compose-material3/macrobenchmark-common/build.gradle
index 6a7a3a7..3d0c2eb 100644
--- a/wear/compose/compose-material3/macrobenchmark-common/build.gradle
+++ b/wear/compose/compose-material3/macrobenchmark-common/build.gradle
@@ -28,6 +28,6 @@
     implementation(project(":compose:ui:ui-tooling"))
     implementation(project(":wear:compose:compose-foundation"))
     implementation(project(":wear:compose:compose-material3"))
-    implementation project(":wear:compose:compose-material3-samples")
+    implementation(project(":wear:compose:compose-material3-samples"))
     implementation("androidx.compose.material:material-icons-core:1.6.0")
 }
\ No newline at end of file
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/FrameCostQuery.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/FrameCostQuery.kt
index d95a012..a467b50 100644
--- a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/FrameCostQuery.kt
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/FrameCostQuery.kt
@@ -16,7 +16,7 @@
 
 package androidx.wear.compose.material3.macrobenchmark.metric
 
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import org.intellij.lang.annotations.Language
 
 /** A copy from aosp/3328563 */
@@ -44,7 +44,7 @@
         INNER JOIN slice post on post.name = 'postAndWait' and slice_is_ancestor(doFrame.id, post.id)
 
         WHERE
-            ${androidx.benchmark.perfetto.processNameLikePkg(packageName)}
+            ${androidx.benchmark.traceprocessor.processNameLikePkg(packageName)}
         AND
             -- remove "incomplete" frames
             a.dur > 0
@@ -52,7 +52,7 @@
             .trimIndent()
 
     internal fun getFrameCost(
-        session: PerfettoTraceProcessor.Session,
+        session: TraceProcessor.Session,
         packageName: String,
     ): List<Double> {
         val queryResultIterator = session.query(query = getFullQuery(packageName))
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/Metric.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/Metric.kt
index aaffb35..3a4c952 100644
--- a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/Metric.kt
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/metric/Metric.kt
@@ -14,20 +14,18 @@
  * limitations under the License.
  */
 
-@file:OptIn(androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi::class)
-
 package androidx.wear.compose.material3.macrobenchmark.metric
 
 import androidx.benchmark.macro.ExperimentalMetricApi
 import androidx.benchmark.macro.TraceMetric
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 
 /** A copy from aosp/3328563 */
 @ExperimentalMetricApi
 class FrameCostMetric : TraceMetric() {
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         val costs =
             FrameCostQuery.getFrameCost(
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
index c4f7d40..055de9a 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
@@ -48,8 +48,8 @@
 import androidx.wear.compose.material3.ArcProgressIndicatorDefaults
 import androidx.wear.compose.material3.Button
 import androidx.wear.compose.material3.CircularProgressIndicator
-import androidx.wear.compose.material3.CircularProgressIndicatorContent
 import androidx.wear.compose.material3.CircularProgressIndicatorDefaults
+import androidx.wear.compose.material3.CircularProgressIndicatorStatic
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.IconButton
 import androidx.wear.compose.material3.IconButtonDefaults
@@ -165,7 +165,7 @@
 
 @Sampled
 @Composable
-fun CircularProgressIndicatorContentSample() {
+fun CircularProgressIndicatorStaticSample() {
     val progress = remember { mutableFloatStateOf(0f) }
     val animatedProgress = remember { Animatable(0f) }
 
@@ -187,9 +187,9 @@
             label = { Text("Animate") },
         )
 
-        // Since CircularProgressIndicatorContent does not have any built-in progress animations,
+        // Since CircularProgressIndicatorStatic does not have any built-in progress animations,
         // we can implement a custom progress animation by using an Animatable progress value.
-        CircularProgressIndicatorContent(
+        CircularProgressIndicatorStatic(
             progress = animatedProgress::value,
             startAngle = 120f,
             endAngle = 60f,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
index b0f7988..42c5506 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
@@ -180,7 +180,7 @@
     @Test
     fun circular_progress_indicator_content(@TestParameter screenSize: ScreenSize) =
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 progress = { 0.25f },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
@@ -191,7 +191,7 @@
     @Test
     fun circular_progress_indicator_content_overflow(@TestParameter screenSize: ScreenSize) =
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 progress = { 1.2f },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
@@ -203,7 +203,7 @@
     @Test
     fun circular_progress_indicator_content_disabled(@TestParameter screenSize: ScreenSize) =
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 progress = { 0.25f },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
@@ -215,7 +215,7 @@
     @Test
     fun circular_progress_indicator_content_custom_color(@TestParameter screenSize: ScreenSize) =
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 progress = { 0.75f },
                 modifier = Modifier.size(200.dp).testTag(TEST_TAG),
                 startAngle = 120f,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
index 0b06023..21b257c 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
@@ -266,7 +266,7 @@
     @Test
     fun supports_testtag() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 progress = { 0.5f },
                 modifier = Modifier.testTag(TEST_TAG)
             )
@@ -280,7 +280,7 @@
         val progress = mutableStateOf(0f)
 
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier =
                     Modifier.testTag(TEST_TAG).semantics {
                         progressBarRangeInfo = ProgressBarRangeInfo(progress.value, 0f..1f)
@@ -300,7 +300,7 @@
     @Test
     fun contains_progress_color() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 1f },
                 colors =
@@ -323,7 +323,7 @@
     @Test
     fun contains_progress_incomplete_color() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0f },
                 colors =
@@ -346,7 +346,7 @@
     @Test
     fun change_start_end_angle() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0.5f },
                 startAngle = 0f,
@@ -375,7 +375,7 @@
     @Test
     fun set_small_progress_value() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0.02f },
                 colors =
@@ -401,7 +401,7 @@
     @Test
     fun set_small_stroke_width() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0.5f },
                 strokeWidth = CircularProgressIndicatorDefaults.smallStrokeWidth,
@@ -427,7 +427,7 @@
     @Test
     fun set_large_stroke_width() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0.5f },
                 strokeWidth = CircularProgressIndicatorDefaults.largeStrokeWidth,
@@ -454,7 +454,7 @@
     @Test
     fun progress_disabled_contains_disabled_colors() {
         setContentWithTheme {
-            CircularProgressIndicatorContent(
+            CircularProgressIndicatorStatic(
                 modifier = Modifier.size(COMPONENT_SIZE).testTag(TEST_TAG),
                 progress = { 0.5f },
                 enabled = false,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Screenshot.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Screenshot.kt
index 0b3bb8d..a5279d4 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Screenshot.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Screenshot.kt
@@ -16,4 +16,4 @@
 
 package androidx.wear.compose.material3
 
-internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/material3"
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/compose/compose-material3"
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
index 85fee0f..007acef 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
@@ -118,7 +118,7 @@
     enabled: Boolean = true,
 ) {
     if (allowProgressOverflow) {
-        CircularProgressIndicatorWithOverflowImpl(
+        AnimatedCircularProgressIndicatorWithOverflowImpl(
             progress,
             modifier,
             startAngle,
@@ -129,7 +129,7 @@
             enabled
         )
     } else {
-        CircularProgressIndicatorImpl(
+        AnimatedCircularProgressIndicatorImpl(
             progress,
             modifier,
             startAngle,
@@ -143,11 +143,13 @@
 }
 
 /**
- * Simple circular progress indicator without any progress animations.
+ * [CircularProgressIndicatorStatic] is a non-animating circular progress indicator. Prefer to use
+ * [CircularProgressIndicator] directly instead of this overload in order to access the recommended
+ * animations, but this overload can be used when custom animations are required.
  *
- * Example of [CircularProgressIndicatorContent] with custom progress animation:
+ * Example of [CircularProgressIndicatorStatic] with custom progress animation:
  *
- * @sample androidx.wear.compose.material3.samples.CircularProgressIndicatorContentSample
+ * @sample androidx.wear.compose.material3.samples.CircularProgressIndicatorStaticSample
  * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
  *   represents completion.
  * @param modifier Modifier to be applied to the CircularProgressIndicator.
@@ -173,9 +175,14 @@
  * @param enabled controls the enabled state. Although this component is not clickable, it can be
  *   contained within a clickable component. When enabled is `false`, this component will appear
  *   visually disabled.
+ * @param overflowColorAlphaFraction Alpha fraction to apply to the overflow color. This can be used
+ *   to implement animated transition from progress color to overflow color. For no animation should
+ *   be set to 1.
+ * @param targetProgress Target value if the progress value is to be animated. Used to determine if
+ *   the min progress values should be enforced. For no animation this should be null.
  */
 @Composable
-fun CircularProgressIndicatorContent(
+fun CircularProgressIndicatorStatic(
     progress: () -> Float,
     modifier: Modifier = Modifier,
     allowProgressOverflow: Boolean = false,
@@ -185,17 +192,91 @@
     strokeWidth: Dp = CircularProgressIndicatorDefaults.largeStrokeWidth,
     gapSize: Dp = CircularProgressIndicatorDefaults.calculateRecommendedGapSize(strokeWidth),
     enabled: Boolean = true,
+    overflowColorAlphaFraction: Float = 1f,
+    targetProgress: (() -> Float)? = null,
 ) {
-    CircularProgressIndicatorContentImpl(
-        progress = progress,
-        modifier = modifier,
-        allowProgressOverflow = allowProgressOverflow,
-        startAngle = startAngle,
-        endAngle = endAngle,
-        colors = colors,
-        strokeWidth = strokeWidth,
-        gapSize = gapSize,
-        enabled = enabled,
+    // Canvas internally uses Spacer.drawBehind.
+    // Using Spacer.drawWithCache to optimize the stroke allocations.
+    Spacer(
+        modifier
+            .clearAndSetSemantics {}
+            .fillMaxSize()
+            .focusable()
+            .drawWithCache {
+                onDrawWithContent {
+                    val fullSweep = 360f - ((startAngle - endAngle) % 360 + 360) % 360
+                    val strokePx = strokeWidth.toPx()
+                    val gapSizePx = gapSize.toPx()
+                    val minSize = min(size.height, size.width)
+                    // Sweep angle between two progress indicator segments.
+                    val gapSweep =
+                        asin((strokePx + gapSizePx) / (minSize - strokePx)).toDegrees() * 2f
+                    val currentProgress = progress()
+                    val hasOverflow = allowProgressOverflow && currentProgress > 1f
+                    val wrappedProgress = wrapProgress(currentProgress, allowProgressOverflow)
+                    var progressSweep = fullSweep * wrappedProgress
+
+                    // If progress sweep or remaining track sweep is smaller than gap sweep, it
+                    // will be shown as a small dot. This dot should never be shown as
+                    // static value, only in progress animation transitions.
+                    val target = if (targetProgress != null) targetProgress() else currentProgress
+                    val wrappedTargetProgress = wrapProgress(target, allowProgressOverflow)
+                    val isValidTarget =
+                        target.isFullInt() ||
+                            (!allowProgressOverflow && target == 1 + GapExtraProgress) ||
+                            wrappedTargetProgress * fullSweep in gapSweep..fullSweep - gapSweep
+                    if (
+                        !wrappedProgress.isFullInt() &&
+                            !isValidTarget &&
+                            floor(currentProgress) == floor(target)
+                    ) {
+                        progressSweep = progressSweep.coerceIn(gapSweep, fullSweep - gapSweep)
+                    }
+
+                    if (hasOverflow) {
+                        // Draw the overflow track background.
+                        drawIndicatorSegment(
+                            startAngle = startAngle + progressSweep,
+                            sweep = fullSweep - progressSweep,
+                            gapSweep = gapSweep,
+                            brush = colors.overflowTrackBrush(enabled, overflowColorAlphaFraction),
+                            strokeWidth = strokePx,
+                        )
+                    } else {
+                        // Draw the track background.
+                        drawIndicatorSegment(
+                            startAngle = startAngle + progressSweep,
+                            sweep = fullSweep - progressSweep,
+                            gapSweep = gapSweep,
+                            brush = colors.trackBrush(enabled),
+                            strokeWidth = strokePx,
+                        )
+                    }
+
+                    if (!allowProgressOverflow && startAngle == endAngle && wrappedProgress == 1f) {
+                        // Draw the full circle with merged gap.
+                        val gapFraction =
+                            (1f + GapExtraProgress - currentProgress).absoluteValue /
+                                GapExtraProgress
+                        drawIndicatorSegment(
+                            startAngle = startAngle,
+                            sweep = progressSweep,
+                            gapSweep = gapFraction,
+                            brush = colors.indicatorBrush(enabled),
+                            strokeWidth = strokePx,
+                        )
+                    } else {
+                        // Draw the indicator.
+                        drawIndicatorSegment(
+                            startAngle = startAngle,
+                            sweep = progressSweep,
+                            gapSweep = gapSweep,
+                            brush = colors.indicatorBrush(enabled),
+                            strokeWidth = strokePx,
+                        )
+                    }
+                }
+            }
     )
 }
 
@@ -272,7 +353,7 @@
 
 /** Animated circular progress indicator implementation without overflow support. */
 @Composable
-private fun CircularProgressIndicatorImpl(
+private fun AnimatedCircularProgressIndicatorImpl(
     progress: () -> Float,
     modifier: Modifier,
     startAngle: Float,
@@ -301,7 +382,7 @@
         }
     }
 
-    CircularProgressIndicatorContentImpl(
+    CircularProgressIndicatorStatic(
         progress = { animatedProgress.value },
         modifier = modifier,
         allowProgressOverflow = false,
@@ -311,13 +392,13 @@
         strokeWidth = strokeWidth,
         gapSize = gapSize,
         enabled = enabled,
-        targetProgress = animatedProgress.targetValue
+        targetProgress = { animatedProgress.targetValue }
     )
 }
 
 /** Animated circular progress indicator implementation with overflow support. */
 @Composable
-private fun CircularProgressIndicatorWithOverflowImpl(
+private fun AnimatedCircularProgressIndicatorWithOverflowImpl(
     progress: () -> Float,
     modifier: Modifier,
     startAngle: Float,
@@ -372,7 +453,7 @@
         }
     }
 
-    CircularProgressIndicatorContentImpl(
+    CircularProgressIndicatorStatic(
         progress = { animatedProgress.value },
         modifier = modifier,
         allowProgressOverflow = true,
@@ -383,140 +464,7 @@
         gapSize = gapSize,
         enabled = enabled,
         overflowColorAlphaFraction = animatedOverflowColor.value,
-        targetProgress = animatedProgress.targetValue
-    )
-}
-
-/**
- * Internal implementation that draws the circular progress indicator. Can be used by both animated
- * and non-animated version of [CircularProgressIndicator].
- *
- * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
- *   represents completion.
- * @param modifier Modifier to be applied to the CircularProgressIndicator.
- * @param allowProgressOverflow When progress overflow is allowed, values smaller than 0.0 will be
- *   coerced to 0, while values larger than 1.0 will be wrapped around and shown as overflow with a
- *   different track color [ProgressIndicatorColors.overflowTrackBrush]. For example values 1.2, 2.2
- *   etc will be shown as 20% progress with the overflow color. When progress overflow is not
- *   allowed, progress values will be coerced into the range 0..1.
- * @param startAngle The starting position of the progress arc, measured clockwise in degrees (0
- *   to 360) from the 3 o'clock position. For example, 0 and 360 represent 3 o'clock, 90 and 180
- *   represent 6 o'clock and 9 o'clock respectively. Default is 270 degrees
- *   [CircularProgressIndicatorDefaults.StartAngle] (top of the screen).
- * @param endAngle The ending position of the progress arc, measured clockwise in degrees (0 to 360)
- *   from the 3 o'clock position. For example, 0 and 360 represent 3 o'clock, 90 and 180 represent 6
- *   o'clock and 9 o'clock respectively. By default equal to [startAngle].
- * @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
- *   color for this progress indicator in different states.
- * @param strokeWidth The stroke width for the progress indicator. The recommended values are
- *   [CircularProgressIndicatorDefaults.largeStrokeWidth] and
- *   [CircularProgressIndicatorDefaults.smallStrokeWidth].
- * @param gapSize The size (in Dp) of the gap between the ends of the progress indicator and the
- *   track. The stroke endcaps are not included in this distance.
- * @param enabled controls the enabled state. Although this component is not clickable, it can be
- *   contained within a clickable component. When enabled is `false`, this component will appear
- *   visually disabled.
- * @param overflowColorAlphaFraction color alpha that is applied to the overflow color to allow for
- *   animated transition from progress color to overflow color. For no animation should be set to 1.
- * @param targetProgress target progress for the animated progress value. Used to determine if the
- *   min progress values should be enforced. For no animation this should be null.
- */
-@Composable
-private fun CircularProgressIndicatorContentImpl(
-    progress: () -> Float,
-    modifier: Modifier = Modifier,
-    allowProgressOverflow: Boolean = false,
-    startAngle: Float = CircularProgressIndicatorDefaults.StartAngle,
-    endAngle: Float = startAngle,
-    colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
-    strokeWidth: Dp = CircularProgressIndicatorDefaults.largeStrokeWidth,
-    gapSize: Dp = CircularProgressIndicatorDefaults.calculateRecommendedGapSize(strokeWidth),
-    enabled: Boolean = true,
-    overflowColorAlphaFraction: Float = 1f,
-    targetProgress: Float? = null
-) {
-    // Canvas internally uses Spacer.drawBehind.
-    // Using Spacer.drawWithCache to optimize the stroke allocations.
-    Spacer(
-        modifier
-            .clearAndSetSemantics {}
-            .fillMaxSize()
-            .focusable()
-            .drawWithCache {
-                onDrawWithContent {
-                    val fullSweep = 360f - ((startAngle - endAngle) % 360 + 360) % 360
-                    val strokePx = strokeWidth.toPx()
-                    val gapSizePx = gapSize.toPx()
-                    val minSize = min(size.height, size.width)
-                    // Sweep angle between two progress indicator segments.
-                    val gapSweep =
-                        asin((strokePx + gapSizePx) / (minSize - strokePx)).toDegrees() * 2f
-                    val hasOverflow = allowProgressOverflow && progress() > 1f
-                    val currentProgress = progress()
-                    val wrappedProgress = wrapProgress(currentProgress, allowProgressOverflow)
-                    var progressSweep = fullSweep * wrappedProgress
-
-                    // If progress sweep or remaining track sweep is smaller than gap sweep, it
-                    // will be shown as a small dot. This dot should never be shown as
-                    // static value, only in progress animation transitions.
-                    val target = targetProgress ?: currentProgress
-                    val wrappedTargetProgress = wrapProgress(target, allowProgressOverflow)
-                    val isValidTarget =
-                        target.isFullInt() ||
-                            (!allowProgressOverflow && target == 1 + GapExtraProgress) ||
-                            wrappedTargetProgress * fullSweep in gapSweep..fullSweep - gapSweep
-                    if (
-                        !wrappedProgress.isFullInt() &&
-                            !isValidTarget &&
-                            floor(currentProgress) == floor(target)
-                    ) {
-                        progressSweep = progressSweep.coerceIn(gapSweep, fullSweep - gapSweep)
-                    }
-
-                    if (hasOverflow) {
-                        // Draw the overflow track background.
-                        drawIndicatorSegment(
-                            startAngle = startAngle + progressSweep,
-                            sweep = fullSweep - progressSweep,
-                            gapSweep = gapSweep,
-                            brush = colors.overflowTrackBrush(enabled, overflowColorAlphaFraction),
-                            strokeWidth = strokePx,
-                        )
-                    } else {
-                        // Draw the track background.
-                        drawIndicatorSegment(
-                            startAngle = startAngle + progressSweep,
-                            sweep = fullSweep - progressSweep,
-                            gapSweep = gapSweep,
-                            brush = colors.trackBrush(enabled),
-                            strokeWidth = strokePx,
-                        )
-                    }
-
-                    if (!allowProgressOverflow && startAngle == endAngle && wrappedProgress == 1f) {
-                        // Draw the full circle with merged gap.
-                        val gapFraction =
-                            (1f + GapExtraProgress - currentProgress).absoluteValue /
-                                GapExtraProgress
-                        drawIndicatorSegment(
-                            startAngle = startAngle,
-                            sweep = progressSweep,
-                            gapSweep = gapFraction,
-                            brush = colors.indicatorBrush(enabled),
-                            strokeWidth = strokePx,
-                        )
-                    } else {
-                        // Draw the indicator.
-                        drawIndicatorSegment(
-                            startAngle = startAngle,
-                            sweep = progressSweep,
-                            gapSweep = gapSweep,
-                            brush = colors.indicatorBrush(enabled),
-                            strokeWidth = strokePx,
-                        )
-                    }
-                }
-            }
+        targetProgress = { animatedProgress.targetValue }
     )
 }
 
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
index 7f2ffef..4cf45b2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
@@ -393,7 +393,7 @@
             .background(iconContainerColor)
     )
 
-    CircularProgressIndicatorContent(
+    CircularProgressIndicatorStatic(
         modifier = Modifier.graphicsLayer { alpha = progressAlphaAnimationFraction.value },
         progress = progress,
         strokeWidth = strokeWidth,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt
index 3653409..f972659 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt
@@ -56,6 +56,7 @@
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealScope
 import androidx.wear.compose.foundation.RevealState
 import androidx.wear.compose.foundation.RevealValue
 import androidx.wear.compose.foundation.SwipeDirection
@@ -402,7 +403,7 @@
 }
 
 @Composable
-internal fun ActionButton(
+internal fun RevealScope.ActionButton(
     revealState: RevealState,
     action: SwipeToRevealAction,
     revealActionType: RevealActionType,
@@ -513,7 +514,6 @@
             horizontalArrangement = Arrangement.Center,
             verticalAlignment = Alignment.CenterVertically
         ) {
-            val density = LocalDensity.current
             val primaryActionTextRevealed = remember { mutableStateOf(false) }
             action.icon?.let {
                 ActionIconWrapper(revealState, iconStartFadeInFraction, iconEndFadeInFraction, it)
@@ -529,12 +529,8 @@
                     }
 
                     LaunchedEffect(revealState.offset) {
-                        val minimumOffsetToRevealPx =
-                            with(density) {
-                                SwipeToRevealDefaults.DoubleActionAnchorWidth.toPx().toInt()
-                            }
                         primaryActionTextRevealed.value =
-                            abs(revealState.offset) > minimumOffsetToRevealPx &&
+                            abs(revealState.offset) > revealOffset &&
                                 (revealState.targetValue == RevealValue.RightRevealed ||
                                     revealState.targetValue == RevealValue.LeftRevealed)
                     }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ContentTransformation.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ContentTransformation.kt
index 11de1de..1c1835b 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ContentTransformation.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ContentTransformation.kt
@@ -16,10 +16,10 @@
 
 package androidx.wear.compose.material3.lazy
 
-import androidx.compose.runtime.State
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.geometry.RoundRect
 import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.CompositingStrategy
 import androidx.compose.ui.graphics.GraphicsLayerScope
 import androidx.compose.ui.graphics.Outline
 import androidx.compose.ui.graphics.Shape
@@ -33,6 +33,7 @@
 ) =
     with(behavior) {
         scrollProgress()?.let {
+            compositingStrategy = CompositingStrategy.Offscreen
             clip = true
             shape =
                 object : Shape {
@@ -57,16 +58,17 @@
                 }
             translationX = size.width * it.contentXOffsetFraction * it.scale
             translationY = -1f * size.height * (1f - it.scale) / 2f
-            alpha = it.contentAlpha.coerceAtMost(0.99f) // Alpha hack.
+            alpha = it.contentAlpha
             scaleX = it.scale
             scaleY = it.scale
         }
     }
 
 internal fun GraphicsLayerScope.contentTransformation(
-    transformState: State<TransformationState?>,
+    transformState: TransformationState?,
 ) =
-    transformState.value?.let {
+    transformState?.let {
+        compositingStrategy = CompositingStrategy.Offscreen
         clip = true
         shape =
             object : Shape {
@@ -90,7 +92,7 @@
             }
         translationX = size.width * it.contentXOffsetFraction * it.scale
         translationY = -1f * size.height * (1f - it.scale) / 2f
-        alpha = it.contentAlpha.coerceAtMost(0.99f) // Alpha hack.
+        alpha = it.contentAlpha
         scaleX = it.scale
         scaleY = it.scale
     }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ScrollTransform.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ScrollTransform.kt
index 232d900..cdf51a8 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ScrollTransform.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/ScrollTransform.kt
@@ -19,7 +19,6 @@
 import androidx.compose.animation.core.Easing
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -35,6 +34,7 @@
 import androidx.compose.ui.graphics.painter.ColorPainter
 import androidx.compose.ui.graphics.painter.Painter
 import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
 import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
 import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope
 import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScrollProgress
@@ -185,33 +185,17 @@
                 .transformedHeight { height, scrollProgress ->
                     // TODO: may be better to create once and update. Still need to ensure readers
                     // are reading a state so they register to updates.
-                    val transformProgress = transformProgress(scrollProgress, updatedSpec)
-                    val scale = transformProgress.compute(updatedSpec.scale, updatedSpec.easing)
-                    val morphedHeight = height.toFloat() // TODO: Implement morphing
-                    transformation.value =
-                        TransformationState(
-                            scale = scale,
-                            containerAlpha =
-                                transformProgress.compute(
-                                    updatedSpec.containerAlpha,
-                                    updatedSpec.easing
-                                ),
-                            contentAlpha =
-                                transformProgress.compute(
-                                    updatedSpec.contentAlpha,
-                                    updatedSpec.easing
-                                ),
-                            morphWidth =
-                                transformProgress.compute(
-                                    updatedSpec.containerWidth,
-                                    updatedSpec.easing
-                                ),
-                            minMorphingHeight = minMorphingHeight,
-                            morphedHeight = morphedHeight
+                    transformationState(
+                            spec = updatedSpec,
+                            itemHeight = height.toFloat(),
+                            minMorphingHeight = minMorphingHeight.value,
+                            scrollProgress = scrollProgress
                         )
-                    (morphedHeight * scale).fastRoundToInt()
+                        .also { transformation.value = it }
+                        .placementHeight
+                        .fastRoundToInt()
                 }
-                .graphicsLayer { contentTransformation(transformation) }
+                .graphicsLayer { contentTransformation(transformation.value) }
     }
 
 /**
@@ -253,33 +237,17 @@
                 .transformedHeight { height, scrollProgress ->
                     // TODO: may be better to create once and update. Still need to ensure readers
                     // are reading a state so they register to updates.
-                    val transformProgress = transformProgress(scrollProgress, updatedSpec)
-                    val scale = transformProgress.compute(updatedSpec.scale, updatedSpec.easing)
-                    val morphedHeight = height.toFloat() // TODO: Implement morphing
-                    transformation.value =
-                        TransformationState(
-                            scale = scale,
-                            containerAlpha =
-                                transformProgress.compute(
-                                    updatedSpec.containerAlpha,
-                                    updatedSpec.easing
-                                ),
-                            contentAlpha =
-                                transformProgress.compute(
-                                    updatedSpec.contentAlpha,
-                                    updatedSpec.easing
-                                ),
-                            morphWidth =
-                                transformProgress.compute(
-                                    updatedSpec.containerWidth,
-                                    updatedSpec.easing
-                                ),
-                            minMorphingHeight = minMorphingHeight,
-                            morphedHeight = morphedHeight
+                    transformationState(
+                            spec = updatedSpec,
+                            itemHeight = height.toFloat(),
+                            minMorphingHeight = minMorphingHeight.value,
+                            scrollProgress = scrollProgress
                         )
-                    (morphedHeight * scale).fastRoundToInt()
+                        .also { transformation.value = it }
+                        .placementHeight
+                        .fastRoundToInt()
                 }
-                .graphicsLayer { contentTransformation(transformation) }
+                .graphicsLayer { contentTransformation(transformState = transformation.value) }
                 .clip(shape)
     }
 
@@ -288,7 +256,7 @@
  * top transition area, the bottom transition area, or neither.
  */
 @JvmInline
-private value class TransitionAreaProgress(private val encodedProgress: Float) {
+internal value class TransitionAreaProgress(private val encodedProgress: Float) {
     // encodedProgress is, going from top to bottom:
     // -1 to 0 for top transition
     // 0 in the center of the screen, no transition
@@ -317,11 +285,7 @@
                 variable.transformationZoneExitFraction,
                 progress
             )
-        return androidx.compose.ui.util.lerp(
-            edgeValue,
-            1f,
-            easing.transform(transformationZoneProgress)
-        )
+        return lerp(edgeValue, 1f, easing.transform(transformationZoneProgress))
     }
 
     companion object {
@@ -342,6 +306,34 @@
     }
 }
 
+/** Uses a TransformationSpec to compute a TransformationState. */
+internal fun transformationState(
+    spec: TransformationSpec,
+    itemHeight: Float,
+    minMorphingHeight: Float?,
+    scrollProgress: TransformingLazyColumnItemScrollProgress
+): TransformationState {
+    val transformProgress = transformProgress(scrollProgress, spec)
+    val scale = transformProgress.compute(spec.scale, spec.easing)
+    val morphedHeight =
+        minMorphingHeight?.let {
+            morphedHeight(
+                scrollProgress = scrollProgress,
+                spec = spec,
+                itemHeight = itemHeight,
+                minMorphingHeight = it
+            )
+        } ?: itemHeight
+
+    return TransformationState(
+        scale = scale,
+        containerAlpha = transformProgress.compute(spec.containerAlpha, spec.easing),
+        contentAlpha = transformProgress.compute(spec.contentAlpha, spec.easing),
+        morphWidth = transformProgress.compute(spec.containerWidth, spec.easing),
+        morphedHeight = morphedHeight
+    )
+}
+
 /** Uses a TransformationSpec to convert a scrollProgress into a transitionProgress. */
 private fun transformProgress(
     scrollProgress: TransformingLazyColumnItemScrollProgress?,
@@ -360,8 +352,7 @@
 
         // Size of each transition area.
         val scalingLine =
-            androidx.compose.ui.util
-                .lerp(spec.minTransitionArea, spec.maxTransitionArea, sizeRatio)
+            lerp(spec.minTransitionArea, spec.maxTransitionArea, sizeRatio)
                 // Ensure the top & bottom transition areas don't overlap.
                 .coerceAtMost((1f + relativeItemHeight) / 2f)
 
@@ -373,14 +364,43 @@
         }
     }
 
+/** Compute new morphing height for the item based on its size and position on the screen. */
+private fun morphedHeight(
+    scrollProgress: TransformingLazyColumnItemScrollProgress,
+    spec: TransformationSpec,
+    itemHeight: Float,
+    minMorphingHeight: Float
+): Float {
+    // Size of the item, relative to the screen
+    val relativeItemHeight = scrollProgress.bottomOffsetFraction - scrollProgress.topOffsetFraction
+    val screenSize = itemHeight / relativeItemHeight
+
+    val growthStartTopOffsetFraction =
+        spec.growthStartScreenFraction - minMorphingHeight / screenSize
+    val growthEndTopOffsetFraction = spec.growthEndScreenFraction - relativeItemHeight
+    // Fraction of how item has grown so far.
+    val heightMorphProgress =
+        // growthStartTopOffsetFraction > growthEndTopOffsetFraction since item has minimum size at
+        // the bottom of the screen.
+        inverseLerp(
+                growthStartTopOffsetFraction,
+                growthEndTopOffsetFraction,
+                scrollProgress.topOffsetFraction
+            )
+            .coerceIn(0f, 1f)
+    return lerp(minMorphingHeight, itemHeight, heightMorphProgress)
+}
+
 // TODO: Decide what we want to compute & store vs compute when needed.
 internal data class TransformationState(
     val containerAlpha: Float,
     val contentAlpha: Float,
     val scale: Float,
     val morphWidth: Float,
-    val minMorphingHeight: State<Float?>,
     val morphedHeight: Float, // Height after morphing, before scaling
-    val contentXOffsetFraction: Float = 0f, // TODO: Implement morphing
-    val backgroundXOffsetFraction: Float = 1f // TODO: Implement morphing
-)
+    val contentXOffsetFraction: Float = 0f, // TODO: Implement horizontal morphing
+    val backgroundXOffsetFraction: Float = 1f // TODO: Implement horizontal morphing
+) {
+    internal val placementHeight: Float
+        get() = morphedHeight * scale
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/TransformationSpec.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/TransformationSpec.kt
index 461eb9a..c0091b4 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/TransformationSpec.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/TransformationSpec.kt
@@ -89,17 +89,28 @@
     /** Configuration for the width of the container. */
     val containerWidth: TransformVariableSpec,
 
-    // TBD
-    val morphHeight: Float,
-    val morphHeightDriftFactor: Float,
-    val morphPivotX: Float,
-    val morphPivotY: Float,
+    /**
+     * Configuration for the screen point where the height morphing starts (item is touching this
+     * screen point with its bottom edge).
+     */
+    val growthStartScreenFraction: Float,
+
+    /**
+     * Configuration for the screen point where the height morphing ends and item is fully expanded
+     * (item is touching this screen point with its bottom edge).
+     */
+    val growthEndScreenFraction: Float,
 ) {
     init {
         // The element height range must be non-empty.
         require(minElementHeight < maxElementHeight) {
             "minElementHeight must be smaller than maxElementHeight"
         }
+
+        // Morphing start point should be below the growth end.
+        require(growthEndScreenFraction < growthStartScreenFraction) {
+            "growthEndScreenFraction must be smaller than growthStartScreenFraction"
+        }
     }
 }
 
@@ -203,10 +214,8 @@
         lerp(start.contentAlpha, stop.contentAlpha, progress),
         lerp(start.scale, stop.scale, progress),
         lerp(start.containerWidth, stop.containerWidth, progress),
-        lerp(start.morphHeight, stop.morphHeight, progress),
-        lerp(start.morphHeightDriftFactor, stop.morphHeightDriftFactor, progress),
-        lerp(start.morphPivotX, stop.morphPivotX, progress),
-        lerp(start.morphPivotY, stop.morphPivotY, progress)
+        lerp(start.growthStartScreenFraction, stop.growthStartScreenFraction, progress),
+        lerp(start.growthEndScreenFraction, stop.growthEndScreenFraction, progress),
     )
 
 /**
diff --git a/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformatioSpecTest.kt b/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformatioSpecTest.kt
deleted file mode 100644
index 8da26a2..0000000
--- a/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformatioSpecTest.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * Copyright 2024 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 androidx.wear.compose.material3
-
-import androidx.compose.animation.core.CubicBezierEasing
-import androidx.wear.compose.material3.lazy.TransformVariableSpec
-import androidx.wear.compose.material3.lazy.TransformationSpec
-import androidx.wear.compose.material3.lazy.responsiveTransformationSpec
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4::class)
-class TransformatioSpecTest {
-
-    private val SPEC1 =
-        TransformationSpec(
-            0.01f,
-            0.02f,
-            0.03f,
-            0.04f,
-            CubicBezierEasing(0.015f, 0.025f, 0.035f, 0.045f),
-            TransformVariableSpec(0.011f, 0.021f, 0.031f, 0.041f),
-            TransformVariableSpec(0.012f, 0.022f, 0.032f, 0.042f),
-            TransformVariableSpec(0.013f, 0.023f, 0.033f, 0.043f),
-            TransformVariableSpec(0.014f, 0.024f, 0.034f, 0.044f),
-            0.05f,
-            0.06f,
-            0.07f,
-            0.08f
-        )
-
-    private val SPEC2 =
-        TransformationSpec(
-            0.1f,
-            0.2f,
-            0.3f,
-            0.4f,
-            CubicBezierEasing(0.15f, 0.25f, 0.35f, 0.45f),
-            TransformVariableSpec(0.11f, 0.21f, 0.31f, 0.41f),
-            TransformVariableSpec(0.12f, 0.22f, 0.32f, 0.42f),
-            TransformVariableSpec(0.13f, 0.23f, 0.33f, 0.43f),
-            TransformVariableSpec(0.14f, 0.24f, 0.34f, 0.44f),
-            0.5f,
-            0.6f,
-            0.7f,
-            0.8f
-        )
-
-    private val SPECS = listOf(200 to SPEC1, 220 to SPEC2)
-
-    @Test fun responsive_spec_coerced_to_min_screen_size() = check_responsive_spec(180, SPEC1)
-
-    @Test fun responsive_spec_for_min_screen_size() = check_responsive_spec(200, SPEC1)
-
-    @Test fun responsive_spec_for_max_screen_size() = check_responsive_spec(220, SPEC2)
-
-    @Test fun responsive_spec_coerced_to_max_screen_size() = check_responsive_spec(221, SPEC2)
-
-    @Test
-    fun responsive_spec_for_mid_screen_size() {
-        val spec = responsiveTransformationSpec(210, SPECS)
-
-        assertEquals(0.055f, spec.minElementHeight, EPSILON)
-        assertEquals(0.11f, spec.maxElementHeight, EPSILON)
-        assertEquals(0.165f, spec.minTransitionArea, EPSILON)
-        assertEquals(0.22f, spec.maxTransitionArea, EPSILON)
-
-        assertEquals(0.066f, spec.contentAlpha.topValue, EPSILON)
-        assertEquals(0.121f, spec.contentAlpha.bottomValue, EPSILON)
-        assertEquals(0.176f, spec.contentAlpha.transformationZoneEnterFraction, EPSILON)
-        assertEquals(0.231f, spec.contentAlpha.transformationZoneExitFraction, EPSILON)
-    }
-
-    @Test(expected = IllegalArgumentException::class)
-    fun responsive_with_no_spec() {
-        responsiveTransformationSpec(200, emptyList())
-    }
-
-    @Test
-    fun responsive_with_one_spec() {
-        val specs1 = listOf(200 to SPEC1)
-
-        assertEquals(SPEC1, responsiveTransformationSpec(199, specs1))
-        assertEquals(SPEC1, responsiveTransformationSpec(200, specs1))
-        assertEquals(SPEC1, responsiveTransformationSpec(201, specs1))
-    }
-
-    @Test
-    fun responsive_with_three_specs() {
-        val specs3 =
-            listOf(
-                100 to SPEC1, // 0.01f, 0.02f, 0.03f, 0.04f
-                200 to SPEC2, // 0.1f, 0.2f, 0.3f, 0.4f
-                300 to
-                    SPEC2.copy(
-                        minElementHeight = 0.5f,
-                        maxElementHeight = 0.6f,
-                        minTransitionArea = 0.7f,
-                        maxTransitionArea = 0.7f,
-                    )
-            )
-
-        assertEquals(SPEC1, responsiveTransformationSpec(100, specs3))
-        assertEquals(0.11f, responsiveTransformationSpec(150, specs3).maxElementHeight, EPSILON)
-        assertEquals(0.1f, responsiveTransformationSpec(200, specs3).minElementHeight, EPSILON)
-        assertEquals(0.55f, responsiveTransformationSpec(250, specs3).maxTransitionArea, EPSILON)
-        assertEquals(specs3.last().second, responsiveTransformationSpec(300, specs3))
-    }
-
-    @Test
-    fun copy_overrides_transformation_area_configuration() {
-        val spec =
-            SPEC2.copy(
-                minElementHeight = 0.51f,
-                maxElementHeight = 0.52f,
-                minTransitionArea = 0.53f,
-                maxTransitionArea = 0.54f
-            )
-
-        assertEquals(0.51f, spec.minElementHeight)
-        assertEquals(0.52f, spec.maxElementHeight)
-        assertEquals(0.53f, spec.minTransitionArea)
-        assertEquals(0.54f, spec.maxTransitionArea)
-        assertEquals(spec.easing, SPEC2.easing)
-        assertEquals(spec.scale, SPEC2.scale)
-        assertEquals(spec.containerAlpha, SPEC2.containerAlpha)
-        assertEquals(spec.contentAlpha, SPEC2.contentAlpha)
-    }
-
-    private val EPSILON = 1e-5f
-
-    private fun check_responsive_spec(screenSize: Int, expectedSpec: TransformationSpec) {
-        val spec = responsiveTransformationSpec(screenSize, SPECS)
-        assertEquals(expectedSpec, spec)
-    }
-}
diff --git a/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformationSpecTest.kt b/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformationSpecTest.kt
new file mode 100644
index 0000000..771846e
--- /dev/null
+++ b/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/TransformationSpecTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2024 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 androidx.wear.compose.material3
+
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.wear.compose.material3.lazy.TransformVariableSpec
+import androidx.wear.compose.material3.lazy.TransformationSpec
+import androidx.wear.compose.material3.lazy.responsiveTransformationSpec
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TransformationSpecTest {
+
+    private val SPEC1 =
+        TransformationSpec(
+            0.01f,
+            0.02f,
+            0.03f,
+            0.04f,
+            CubicBezierEasing(0.015f, 0.025f, 0.035f, 0.045f),
+            TransformVariableSpec(0.011f, 0.021f, 0.031f, 0.041f),
+            TransformVariableSpec(0.012f, 0.022f, 0.032f, 0.042f),
+            TransformVariableSpec(0.013f, 0.023f, 0.033f, 0.043f),
+            TransformVariableSpec(0.014f, 0.024f, 0.034f, 0.044f),
+            0.95f,
+            0.86f,
+        )
+
+    private val SPEC2 =
+        TransformationSpec(
+            0.1f,
+            0.2f,
+            0.3f,
+            0.4f,
+            CubicBezierEasing(0.15f, 0.25f, 0.35f, 0.45f),
+            TransformVariableSpec(0.11f, 0.21f, 0.31f, 0.41f),
+            TransformVariableSpec(0.12f, 0.22f, 0.32f, 0.42f),
+            TransformVariableSpec(0.13f, 0.23f, 0.33f, 0.43f),
+            TransformVariableSpec(0.14f, 0.24f, 0.34f, 0.44f),
+            0.95f,
+            0.86f,
+        )
+
+    private val SPECS = listOf(200 to SPEC1, 220 to SPEC2)
+
+    @Test fun responsive_spec_coerced_to_min_screen_size() = check_responsive_spec(180, SPEC1)
+
+    @Test fun responsive_spec_for_min_screen_size() = check_responsive_spec(200, SPEC1)
+
+    @Test fun responsive_spec_for_max_screen_size() = check_responsive_spec(220, SPEC2)
+
+    @Test fun responsive_spec_coerced_to_max_screen_size() = check_responsive_spec(221, SPEC2)
+
+    @Test
+    fun responsive_spec_for_mid_screen_size() {
+        val spec = responsiveTransformationSpec(210, SPECS)
+
+        assertEquals(0.055f, spec.minElementHeight, EPSILON)
+        assertEquals(0.11f, spec.maxElementHeight, EPSILON)
+        assertEquals(0.165f, spec.minTransitionArea, EPSILON)
+        assertEquals(0.22f, spec.maxTransitionArea, EPSILON)
+
+        assertEquals(0.066f, spec.contentAlpha.topValue, EPSILON)
+        assertEquals(0.121f, spec.contentAlpha.bottomValue, EPSILON)
+        assertEquals(0.176f, spec.contentAlpha.transformationZoneEnterFraction, EPSILON)
+        assertEquals(0.231f, spec.contentAlpha.transformationZoneExitFraction, EPSILON)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun responsive_with_no_spec() {
+        responsiveTransformationSpec(200, emptyList())
+    }
+
+    @Test
+    fun responsive_with_one_spec() {
+        val specs1 = listOf(200 to SPEC1)
+
+        assertEquals(SPEC1, responsiveTransformationSpec(199, specs1))
+        assertEquals(SPEC1, responsiveTransformationSpec(200, specs1))
+        assertEquals(SPEC1, responsiveTransformationSpec(201, specs1))
+    }
+
+    @Test
+    fun responsive_with_three_specs() {
+        val specs3 =
+            listOf(
+                100 to SPEC1, // 0.01f, 0.02f, 0.03f, 0.04f
+                200 to SPEC2, // 0.1f, 0.2f, 0.3f, 0.4f
+                300 to
+                    SPEC2.copy(
+                        minElementHeight = 0.5f,
+                        maxElementHeight = 0.6f,
+                        minTransitionArea = 0.7f,
+                        maxTransitionArea = 0.7f,
+                    )
+            )
+
+        assertEquals(SPEC1, responsiveTransformationSpec(100, specs3))
+        assertEquals(0.11f, responsiveTransformationSpec(150, specs3).maxElementHeight, EPSILON)
+        assertEquals(0.1f, responsiveTransformationSpec(200, specs3).minElementHeight, EPSILON)
+        assertEquals(0.55f, responsiveTransformationSpec(250, specs3).maxTransitionArea, EPSILON)
+        assertEquals(specs3.last().second, responsiveTransformationSpec(300, specs3))
+    }
+
+    @Test
+    fun copy_overrides_transformation_area_configuration() {
+        val spec =
+            SPEC2.copy(
+                minElementHeight = 0.51f,
+                maxElementHeight = 0.52f,
+                minTransitionArea = 0.53f,
+                maxTransitionArea = 0.54f
+            )
+
+        assertEquals(0.51f, spec.minElementHeight)
+        assertEquals(0.52f, spec.maxElementHeight)
+        assertEquals(0.53f, spec.minTransitionArea)
+        assertEquals(0.54f, spec.maxTransitionArea)
+        assertEquals(spec.easing, SPEC2.easing)
+        assertEquals(spec.scale, SPEC2.scale)
+        assertEquals(spec.containerAlpha, SPEC2.containerAlpha)
+        assertEquals(spec.contentAlpha, SPEC2.contentAlpha)
+    }
+
+    private val EPSILON = 1e-5f
+
+    private fun check_responsive_spec(screenSize: Int, expectedSpec: TransformationSpec) {
+        val spec = responsiveTransformationSpec(screenSize, SPECS)
+        assertEquals(expectedSpec, spec)
+    }
+}
diff --git a/wear/compose/integration-tests/demos/build.gradle b/wear/compose/integration-tests/demos/build.gradle
index df57a96..17c8635 100644
--- a/wear/compose/integration-tests/demos/build.gradle
+++ b/wear/compose/integration-tests/demos/build.gradle
@@ -26,8 +26,8 @@
     defaultConfig {
         applicationId = "androidx.wear.compose.integration.demos"
         minSdk = 25
-        versionCode = 58
-        versionName = "1.58"
+        versionCode = 59
+        versionName = "1.59"
     }
 
     buildTypes {
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
index 9c2032d..bb846d3 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
@@ -16,7 +16,6 @@
 
 package androidx.wear.compose.integration.demos
 
-import android.annotation.SuppressLint
 import android.os.Build
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.getValue
@@ -95,7 +94,6 @@
 import java.time.LocalDate
 import java.time.LocalTime
 
-@SuppressLint("ClassVerificationFailure")
 val WearMaterialDemos =
     DemoCategory(
         "Material",
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
index 5ef0915..223bcbe 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
@@ -16,7 +16,6 @@
 
 package androidx.wear.compose.integration.demos
 
-import android.annotation.SuppressLint
 import android.content.Context
 import android.os.Build
 import android.view.accessibility.AccessibilityManager
@@ -96,7 +95,6 @@
  * @param modifier the modifiers for the `Box` containing the UI elements.
  * @param time the initial value to seed the picker with.
  */
-@SuppressLint("ClassVerificationFailure")
 @RequiresApi(Build.VERSION_CODES.O)
 @Composable
 public fun TimePicker(
@@ -270,7 +268,6 @@
  * @param modifier the modifiers for the `Column` containing the UI elements.
  * @param time the initial value to seed the picker with.
  */
-@SuppressLint("ClassVerificationFailure")
 @RequiresApi(Build.VERSION_CODES.O)
 @Composable
 public fun TimePickerWith12HourClock(
@@ -471,7 +468,6 @@
  * @param fromDate the minimum date to be selected in picker
  * @param toDate the maximum date to be selected in picker
  */
-@SuppressLint("ClassVerificationFailure")
 @RequiresApi(Build.VERSION_CODES.O)
 @Composable
 public fun DatePicker(
@@ -871,7 +867,6 @@
     require(date in fromDate..toDate) { "date should lie between fromDate and toDate" }
 }
 
-@SuppressLint("ClassVerificationFailure")
 @RequiresApi(Build.VERSION_CODES.O)
 private fun getMonthNames(pattern: String): List<String> {
     val monthFormatter = DateTimeFormatter.ofPattern(pattern)
diff --git a/wear/compose/integration-tests/macrobenchmark-target/build.gradle b/wear/compose/integration-tests/macrobenchmark-target/build.gradle
index 25eb26b49..dbbe105 100644
--- a/wear/compose/integration-tests/macrobenchmark-target/build.gradle
+++ b/wear/compose/integration-tests/macrobenchmark-target/build.gradle
@@ -44,10 +44,10 @@
     implementation(project(":compose:ui:ui-tooling"))
     implementation(project(":activity:activity-compose"))
     implementation(project(":profileinstaller:profileinstaller"))
-    implementation project(":wear:compose:compose-foundation")
-    implementation project(":wear:compose:compose-material")
-    implementation project(":wear:compose:compose-material3")
-    implementation project(":wear:compose:compose-navigation")
+    implementation(project(":wear:compose:compose-foundation"))
+    implementation(project(":wear:compose:compose-material"))
+    implementation(project(":wear:compose:compose-material3"))
+    implementation(project(":wear:compose:compose-navigation"))
     implementation(project(":compose:runtime:runtime-tracing"))
     implementation(project(":tracing:tracing-perfetto"))
     implementation(project(":tracing:tracing-perfetto-binary"))
diff --git a/wear/compose/integration-tests/macrobenchmark/build.gradle b/wear/compose/integration-tests/macrobenchmark/build.gradle
index 0ff8699..6526e1b 100644
--- a/wear/compose/integration-tests/macrobenchmark/build.gradle
+++ b/wear/compose/integration-tests/macrobenchmark/build.gradle
@@ -47,5 +47,5 @@
     implementation(libs.testCore)
     implementation(libs.testRunner)
     implementation(libs.testUiautomator)
-    implementation project(":wear:compose:compose-material")
+    implementation(project(":wear:compose:compose-material"))
 }
diff --git a/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/CompositionMetric.kt b/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/CompositionMetric.kt
index 25f237b..760ca99 100644
--- a/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/CompositionMetric.kt
+++ b/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/CompositionMetric.kt
@@ -17,17 +17,16 @@
 
 import androidx.benchmark.macro.ExperimentalMetricApi
 import androidx.benchmark.macro.TraceMetric
-import androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.traceprocessor.TraceProcessor
 import kotlin.time.Duration.Companion.nanoseconds
 import kotlin.time.DurationUnit
 
 @OptIn(ExperimentalMetricApi::class)
 internal class CompositionMetric(private val composable: String) : TraceMetric() {
-    @OptIn(ExperimentalMetricApi::class, ExperimentalPerfettoTraceProcessorApi::class)
+    @OptIn(ExperimentalMetricApi::class)
     override fun getMeasurements(
         captureInfo: CaptureInfo,
-        traceSession: PerfettoTraceProcessor.Session
+        traceSession: TraceProcessor.Session
     ): List<Measurement> {
         val shortName = composable.substringAfterLast(".")
 
diff --git a/wear/protolayout/protolayout-material/build.gradle b/wear/protolayout/protolayout-material/build.gradle
index 464a703..da521c6 100644
--- a/wear/protolayout/protolayout-material/build.gradle
+++ b/wear/protolayout/protolayout-material/build.gradle
@@ -67,9 +67,6 @@
     defaultConfig {
         minSdk = 26
     }
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/wear/wear-protolayout-material"
-    }
     namespace = "androidx.wear.protolayout.material"
 }
 
@@ -78,4 +75,5 @@
     type = LibraryType.PUBLISHED_LIBRARY
     inceptionYear = "2023"
     description = "Material components library for ProtoLayout."
+    addGoldenImageAssets()
 }
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
index 7dd4feb..92472c0 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenTest.java
@@ -21,6 +21,7 @@
 import static androidx.wear.protolayout.material.RunnerUtils.convertToTestParameters;
 import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.protolayout.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.protolayout.material.TestCasesGenerator.generateTestCases;
 import static androidx.wear.protolayout.material.TestCasesGenerator.generateTextTestCasesLtrOnly;
 import static androidx.wear.protolayout.material.TestCasesGenerator.generateTextTestCasesRtlOnly;
@@ -53,7 +54,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-protolayout-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public MaterialGoldenTest(String expected, TestCase testCase) {
         mTestCase = testCase;
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
index 24dd65b..aafaea8 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/MaterialGoldenXLTest.java
@@ -23,6 +23,7 @@
 import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.protolayout.material.RunnerUtils.setFontScale;
 import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.protolayout.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.protolayout.material.TestCasesGenerator.XXXL_SCALE_SUFFIX;
 import static androidx.wear.protolayout.material.TestCasesGenerator.generateTestCases;
 import static androidx.wear.protolayout.material.TestCasesGenerator.generateTextTestCasesLtrOnly;
@@ -63,7 +64,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-protolayout-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public MaterialGoldenXLTest(String expected, TestCase testCase) {
         mTestCase = testCase;
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/Screenshot.kt b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/Screenshot.kt
new file mode 100644
index 0000000..0d86e36
--- /dev/null
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/Screenshot.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.wear.protolayout.material
+
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/protolayout/protolayout-material"
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
index 9685084e..38df639 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenTest.java
@@ -21,6 +21,7 @@
 import static androidx.wear.protolayout.material.RunnerUtils.convertToTestParameters;
 import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.protolayout.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.protolayout.material.layouts.TestCasesGenerator.generateTestCases;
 
 import android.content.Context;
@@ -51,7 +52,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-protolayout-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public LayoutsGoldenTest(String expected, TestCase testCase) {
         mTestCase = testCase;
diff --git a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
index dc82303..a10bb47 100644
--- a/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
+++ b/wear/protolayout/protolayout-material/src/androidTest/java/androidx/wear/protolayout/material/layouts/LayoutsGoldenXLTest.java
@@ -23,6 +23,7 @@
 import static androidx.wear.protolayout.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.protolayout.material.RunnerUtils.setFontScale;
 import static androidx.wear.protolayout.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.protolayout.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.protolayout.material.layouts.TestCasesGenerator.XXXL_SCALE_SUFFIX;
 import static androidx.wear.protolayout.material.layouts.TestCasesGenerator.generateTestCases;
 
@@ -59,7 +60,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-protolayout-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public LayoutsGoldenXLTest(String expected, TestCase testCase) {
         mTestCase = testCase;
diff --git a/wear/protolayout/protolayout-material3/build.gradle b/wear/protolayout/protolayout-material3/build.gradle
index e6c36ce..f10a7fb 100644
--- a/wear/protolayout/protolayout-material3/build.gradle
+++ b/wear/protolayout/protolayout-material3/build.gradle
@@ -71,10 +71,6 @@
     defaultConfig {
         minSdk = 26
     }
-
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/wear/wear-protolayout-material3"
-    }
     namespace = "androidx.wear.protolayout.material3"
 }
 
@@ -85,4 +81,5 @@
     inceptionYear = "2024"
     description = "Material3 components library for ProtoLayout."
     samples(project(":wear:protolayout:protolayout-material3-samples"))
+    addGoldenImageAssets()
 }
diff --git a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
index 50f3ef6..12a274b 100644
--- a/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
+++ b/wear/protolayout/protolayout-material3/src/androidTest/java/androidx/wear/protolayout/material3/MaterialGoldenTest.kt
@@ -30,7 +30,7 @@
     @JvmField
     @Rule
     var mScreenshotRule: AndroidXScreenshotTestRule =
-        AndroidXScreenshotTestRule("wear/wear-protolayout-material3")
+        AndroidXScreenshotTestRule("wear/protolayout/protolayout-material3")
 
     @Test
     fun test() {
diff --git a/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt b/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt
new file mode 100644
index 0000000..298260a9
--- /dev/null
+++ b/wear/protolayout/protolayout-material3/src/test/java/androidx/wear/protolayout/material3/EdgeButtonTest.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2024 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 androidx.wear.protolayout.material3
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.wear.protolayout.ActionBuilders.LaunchAction
+import androidx.wear.protolayout.DeviceParametersBuilders
+import androidx.wear.protolayout.LayoutElementBuilders.Image
+import androidx.wear.protolayout.ModifiersBuilders.Clickable
+import androidx.wear.protolayout.TypeBuilders.StringProp
+import androidx.wear.protolayout.expression.AppDataKey
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32
+import androidx.wear.protolayout.material3.EdgeButtonDefaults.BOTTOM_MARGIN_DP
+import androidx.wear.protolayout.material3.EdgeButtonDefaults.EDGE_BUTTON_HEIGHT_DP
+import androidx.wear.protolayout.testing.LayoutElementAssertionsProvider
+import androidx.wear.protolayout.testing.LayoutElementMatcher
+import androidx.wear.protolayout.testing.hasColor
+import androidx.wear.protolayout.testing.hasContentDescription
+import androidx.wear.protolayout.testing.hasHeight
+import androidx.wear.protolayout.testing.hasImage
+import androidx.wear.protolayout.testing.hasText
+import androidx.wear.protolayout.testing.hasWidth
+import androidx.wear.protolayout.testing.isClickable
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(AndroidJUnit4::class)
+@DoNotInstrument
+class EdgeButtonTest {
+    @Test
+    fun containerSize() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
+            .onRoot()
+            .assert(hasWidth(DEVICE_CONFIGURATION.screenWidthDp.toDp()))
+            .assert(hasHeight((EDGE_BUTTON_HEIGHT_DP + BOTTOM_MARGIN_DP).toDp()))
+    }
+
+    @Test
+    fun visibleHeight() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
+            .onElement(isClickable())
+            .assert(hasHeight(EDGE_BUTTON_HEIGHT_DP.toDp()))
+    }
+
+    @Test
+    fun contentDescription() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
+            .onElement(isClickable())
+            .assert(hasContentDescription(CONTENT_DESCRIPTION.value))
+    }
+
+    @Test
+    fun defaultBackgroundColor() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
+            .onElement(isClickable())
+            .assert(hasColor(COLOR_SCHEME.primary.argb))
+    }
+
+    @Test
+    fun icon() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON).onElement(hasImage(RES_ID)).assertExists()
+    }
+
+    @Test
+    fun iconColor() {
+        LayoutElementAssertionsProvider(ICON_EDGE_BUTTON)
+            .onElement(hasImage(RES_ID))
+            .assert(hasColor(COLOR_SCHEME.onPrimary.argb))
+    }
+
+    @Test
+    fun customColors() {
+        val queryProvider =
+            LayoutElementAssertionsProvider(
+                materialScope(CONTEXT, DEVICE_CONFIGURATION, allowDynamicTheme = false) {
+                    iconEdgeButton(
+                        onClick = CLICKABLE,
+                        contentDescription = CONTENT_DESCRIPTION,
+                        colors =
+                            EdgeButtonColors(
+                                COLOR_SCHEME.tertiaryContainer,
+                                COLOR_SCHEME.onTertiary
+                            ),
+                    ) {
+                        icon(RES_ID)
+                    }
+                }
+            )
+
+        queryProvider.onElement(isClickable()).assert(hasColor(COLOR_SCHEME.tertiaryContainer.argb))
+        queryProvider
+            .onElement(LayoutElementMatcher("Element type is Image") { it is Image })
+            .assert(hasColor(COLOR_SCHEME.onTertiary.argb))
+    }
+
+    @Test
+    fun staticText() {
+        val label = "static text"
+        val textEdgeButton =
+            materialScope(CONTEXT, DEVICE_CONFIGURATION, allowDynamicTheme = false) {
+                textEdgeButton(onClick = CLICKABLE, contentDescription = CONTENT_DESCRIPTION) {
+                    text(label.prop())
+                }
+            }
+
+        LayoutElementAssertionsProvider(textEdgeButton)
+            .onElement(hasText(label))
+            .assert(hasColor(COLOR_SCHEME.onPrimary.argb))
+    }
+
+    @Test
+    fun dynamicText() {
+        val label = "test text"
+        val stateKey = AppDataKey<DynamicInt32>("testKey")
+        val dynamicLabel =
+            StringProp.Builder(label)
+                .setDynamicValue(DynamicInt32.from(stateKey).times(2).format())
+                .build()
+
+        val queryProvider =
+            LayoutElementAssertionsProvider(
+                materialScope(CONTEXT, DEVICE_CONFIGURATION, allowDynamicTheme = false) {
+                    textEdgeButton(onClick = CLICKABLE, contentDescription = CONTENT_DESCRIPTION) {
+                        text(dynamicLabel)
+                    }
+                }
+            )
+
+        queryProvider.onElement(hasText(dynamicLabel)).assert(hasColor(COLOR_SCHEME.onPrimary.argb))
+    }
+
+    companion object {
+        private val CONTEXT = getApplicationContext() as Context
+        private val COLOR_SCHEME = ColorScheme()
+
+        private val DEVICE_CONFIGURATION =
+            DeviceParametersBuilders.DeviceParameters.Builder()
+                .setScreenWidthDp(192)
+                .setScreenHeightDp(192)
+                .build()
+
+        private val CLICKABLE =
+            Clickable.Builder()
+                .setOnClick(LaunchAction.Builder().build())
+                .setId("action_id")
+                .build()
+
+        private val CONTENT_DESCRIPTION = "it is an edge button".prop()
+
+        private val RES_ID = "resId"
+        private val ICON_EDGE_BUTTON =
+            materialScope(CONTEXT, DEVICE_CONFIGURATION, allowDynamicTheme = false) {
+                iconEdgeButton(onClick = CLICKABLE, contentDescription = CONTENT_DESCRIPTION) {
+                    icon(RES_ID)
+                }
+            }
+    }
+}
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/layout.proto b/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
index 67b8f26..a81a412 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/layout.proto
@@ -736,16 +736,16 @@
   Shadow shadow = 2;
 }
 
-// A dashed arc line that can be placed in an Arc container. It is an arc line made up of arc line
-// segments separated by gaps.
+// A dashed arc line. It is an arc line made up of arc line segments separated by gaps, and can be
+// placed in an Arc container.
 message DashedArcLine {
   // The length of this line in degrees, including gaps. <setter> If not defined,
   // defaults to 0.</setter>
   //
-  // When using a dynamic value, make sure to specify the bounding constraints
+  // <setter>When using a dynamic value, make sure to specify the bounding constraints
   // for the affected layout element through {@code
   // setLayoutConstraintsForDynamicLength(AngularLayoutConstraint)} otherwise
-  // {@code build()} fails.
+  // {@code build()} fails.<setter>
   DegreesProp length = 1;
 
   // The thickness of this line. <setter> If not defined, defaults to 0.</setter>
@@ -757,8 +757,8 @@
   // Modifiers for this element.
   ArcModifiers modifiers = 4;
 
-  // The direction in which this line is drawn.<setter> If not set, defaults to
-  // ARC_DIRECTION_CLOCKWISE.</setter>
+  // The direction in which this line is drawn.<setter> If not set, defaults to the Arc container's
+  // direction where it is placed in.</setter>
   ArcDirectionProp arc_direction = 5;
 
   // The dashed line pattern which describes how the arc line is segmented by gaps.
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ArcWidgetHelper.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ArcWidgetHelper.java
new file mode 100644
index 0000000..d39ba13b
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ArcWidgetHelper.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2024 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 androidx.wear.protolayout.renderer.inflater;
+
+import static androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.isRtlLayoutDirectionFromLocale;
+import static java.lang.Math.min;
+
+import android.view.View;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcDirection;
+
+import org.jspecify.annotations.NonNull;
+
+final class ArcWidgetHelper {
+  /**
+   * Returns true when the given point is inside the arc. In particular, the coordinates should be
+   * considered as if the arc was drawn centered at the default angle (12 o clock).
+   */
+  // This is Copy from
+  // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/wear/src/main/java/androidx/wear/widget/CurvedTextView.java;l=248;bpv=1;bpt=0;drc=03209658917989bcc65d389ee19f83ec6a53174e
+  static boolean isPointInsideArcArea(
+      View view, float x, float y, float arcWidth, float arcSweepAngle) {
+    float radius2 = min(view.getWidth(), view.getHeight()) / 2f - view.getPaddingTop();
+    float radius1 = radius2 - arcWidth;
+
+    float dx = x - view.getWidth() / 2f;
+    float dy = y - view.getHeight() / 2f;
+
+    float r2 = dx * dx + dy * dy;
+    if (r2 < radius1 * radius1 || r2 > radius2 * radius2) {
+      return false;
+    }
+
+    // Since we are symmetrical on the Y-axis, we can constrain the angle to the x>=0 quadrants.
+    float angle = (float) Math.toDegrees(Math.atan2(Math.abs(dx), -dy));
+    return angle < arcSweepAngle / 2;
+  }
+
+  static int getSignForClockwise(
+      @NonNull View view, @NonNull ArcDirection arcDirection, int defaultValue) {
+    switch (arcDirection) {
+      case ARC_DIRECTION_CLOCKWISE:
+        return 1;
+      case ARC_DIRECTION_COUNTER_CLOCKWISE:
+        return -1;
+      case ARC_DIRECTION_NORMAL:
+        return isRtlLayoutDirectionFromLocale() ? -1 : 1;
+      case UNRECOGNIZED:
+        return view.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? 1 : -1;
+    }
+    return defaultValue;
+  }
+
+  private ArcWidgetHelper() {}
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
index 8e799ce..1262b78 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
@@ -131,6 +131,8 @@
 import androidx.wear.protolayout.proto.LayoutElementProto.Box;
 import androidx.wear.protolayout.proto.LayoutElementProto.Column;
 import androidx.wear.protolayout.proto.LayoutElementProto.ContentScaleMode;
+import androidx.wear.protolayout.proto.LayoutElementProto.DashedArcLine;
+import androidx.wear.protolayout.proto.LayoutElementProto.DashedLinePattern;
 import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.FontFeatureSetting;
 import androidx.wear.protolayout.proto.LayoutElementProto.FontSetting;
@@ -191,6 +193,7 @@
 import androidx.wear.protolayout.renderer.common.ProviderStatsLogger.InflaterStatsLogger;
 import androidx.wear.protolayout.renderer.common.RenderingArtifact;
 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline;
+import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline.PipelineMaker;
 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LayoutInfo;
 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LinearLayoutProperties;
 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingFrameLayoutParams;
@@ -350,12 +353,12 @@
     public static final class InflateResult {
         public final ViewGroup inflateParent;
         public final View firstChild;
-        private final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker;
+        private final Optional<PipelineMaker> mPipelineMaker;
 
         InflateResult(
                 ViewGroup inflateParent,
                 View firstChild,
-                Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+                Optional<PipelineMaker> pipelineMaker) {
             this.inflateParent = inflateParent;
             this.firstChild = firstChild;
             this.mPipelineMaker = pipelineMaker;
@@ -379,13 +382,13 @@
         final List<InflatedView> mInflatedViews;
         final RenderedMetadata mRenderedMetadataAfterMutation;
         final NodeFingerprint mPreMutationRootNodeFingerprint;
-        final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker;
+        final Optional<PipelineMaker> mPipelineMaker;
 
         ViewGroupMutation(
                 List<InflatedView> inflatedViews,
                 RenderedMetadata renderedMetadataAfterMutation,
                 NodeFingerprint preMutationRootNodeFingerprint,
-                Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+                Optional<PipelineMaker> pipelineMaker) {
             this.mInflatedViews = inflatedViews;
             this.mRenderedMetadataAfterMutation = renderedMetadataAfterMutation;
             this.mPreMutationRootNodeFingerprint = preMutationRootNodeFingerprint;
@@ -1245,7 +1248,7 @@
             FontStyle style,
             TextView textView,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker,
+            Optional<PipelineMaker> pipelineMaker,
             boolean isAutoSizeAllowed) {
         // Note: Underline must be applied as a Span to work correctly (as opposed to using
         // TextPaint#setTextUnderline). This is applied in the caller instead.
@@ -1705,7 +1708,7 @@
             @NonNull View view,
             @NonNull Transformation transformation,
             @NonNull String posId,
-            @NonNull Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            @NonNull Optional<PipelineMaker> pipelineMaker) {
         // In a composite transformation, the order of applying the individual transformations
         // does not affect the result, as Android view does the transformation in fixed order by
         // first scale, then rotate then translate.
@@ -1786,7 +1789,7 @@
             Consumer<Float> consumerOffsetDp,
             Consumer<Float> consumerLocationRatio,
             @NonNull String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         switch (pivotDimension.getInnerCase()) {
             case OFFSET_DP:
                 DpProp offset = pivotDimension.getOffsetDp();
@@ -1819,7 +1822,7 @@
             @Nullable View wrapper, // The wrapper view for layout sizing, if any
             @NonNull Modifiers modifiers,
             @NonNull String posId,
-            @NonNull Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            @NonNull Optional<PipelineMaker> pipelineMaker) {
         if (modifiers.hasVisible()) {
             applyVisible(
                     view,
@@ -1896,7 +1899,7 @@
             View view,
             BoolProp visible,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker,
+            Optional<PipelineMaker> pipelineMaker,
             Function<Boolean, Integer> toViewVisibility) {
         handleProp(
                 visible,
@@ -2359,7 +2362,7 @@
             String rowPosId,
             boolean includeChildren,
             LayoutInfo.Builder layoutInfoBuilder,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         ContainerDimension width = row.hasWidth() ? row.getWidth() : CONTAINER_DIMENSION_DEFAULT;
         ContainerDimension height = row.hasHeight() ? row.getHeight() : CONTAINER_DIMENSION_DEFAULT;
 
@@ -2418,7 +2421,7 @@
             String boxPosId,
             boolean includeChildren,
             LayoutInfo.Builder layoutInfoBuilder,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         ContainerDimension width = box.hasWidth() ? box.getWidth() : CONTAINER_DIMENSION_DEFAULT;
         ContainerDimension height = box.hasHeight() ? box.getHeight() : CONTAINER_DIMENSION_DEFAULT;
 
@@ -2717,8 +2720,8 @@
             ParentViewWrapper parentViewWrapper,
             ArcSpacer spacer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
-        float lengthDegrees = 0;
+            Optional<PipelineMaker> pipelineMaker) {
+        float length = 0;
         int thicknessPx = safeDpToPx(spacer.getThickness());
         WearCurvedSpacer space = new WearCurvedSpacer(mUiContext);
         ArcLayout.LayoutParams layoutParams =
@@ -2728,7 +2731,8 @@
             final AngularDimension angularLength = spacer.getAngularLength();
             switch (angularLength.getInnerCase()) {
                 case DEGREES:
-                    lengthDegrees = max(0, angularLength.getDegrees().getValue());
+                    length = max(0, angularLength.getDegrees().getValue());
+                    space.setSweepAngleDegrees(length);
                     break;
 
                 case EXPANDED_ANGULAR_DIMENSION:
@@ -2758,20 +2762,21 @@
                     }
 
                 case DP:
-                    // TODO: b/377325905 - ArcSpacer accepts Dp length.
+                    length = max(0, safeDpToPx(angularLength.getDp().getValue()));
+                    space.setLengthPx(length);
                     break;
 
                 case INNER_NOT_SET:
                     break;
             }
         } else {
-            lengthDegrees = max(0, spacer.getLength().getValue());
+            length = max(0, spacer.getLength().getValue());
+            space.setSweepAngleDegrees(length);
         }
 
-        if (lengthDegrees == 0 && thicknessPx == 0) {
+        if (length == 0 && thicknessPx == 0) {
             return null;
         }
-        space.setSweepAngleDegrees(lengthDegrees);
         space.setThickness(thicknessPx);
 
         View wrappedView =
@@ -2809,7 +2814,7 @@
             ParentViewWrapper parentViewWrapper,
             Text text,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         TextView textView = newThemedTextView();
 
         LayoutParams layoutParams = generateDefaultLayoutParams();
@@ -3018,7 +3023,7 @@
             ParentViewWrapper parentViewWrapper,
             ArcText text,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         CurvedTextView textView = newThemedCurvedTextView();
 
         LayoutParams layoutParams = generateDefaultLayoutParams();
@@ -3325,7 +3330,7 @@
             ParentViewWrapper parentViewWrapper,
             ArcLine line,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         float lengthDegrees = 0;
         if (line.hasAngularLength()) {
             if (line.getAngularLength().getInnerCase() == ArcLineLength.InnerCase.DEGREES) {
@@ -3346,13 +3351,6 @@
         try {
             lineView.setUpdatesEnabled(false);
 
-            // A ArcLineView must always be the same width/height as its parent, so it can draw the
-            // line properly inside of those bounds.
-            ArcLayout.LayoutParams layoutParams =
-                    new ArcLayout.LayoutParams(generateDefaultLayoutParams());
-            layoutParams.width = LayoutParams.MATCH_PARENT;
-            layoutParams.height = LayoutParams.MATCH_PARENT;
-
             if (line.hasBrush()) {
                 lineView.setBrush(line.getBrush());
             } else if (line.hasColor()) {
@@ -3392,6 +3390,7 @@
 
             DegreesProp length = DegreesProp.getDefaultInstance();
 
+            float arcLayoutWeight = 0;
             if (line.hasAngularLength()) {
                 final ArcLineLength angularLength = line.getAngularLength();
                 switch (angularLength.getInnerCase()) {
@@ -3405,10 +3404,10 @@
                         {
                             ExpandedAngularDimensionProp expandedAngularDimension =
                                     angularLength.getExpandedAngularDimension();
-                            layoutParams.setWeight(
+                            arcLayoutWeight =
                                     expandedAngularDimension.hasLayoutWeight()
                                             ? expandedAngularDimension.getLayoutWeight().getValue()
-                                            : 1.0f);
+                                            : 1.0f;
                             length = DegreesProp.getDefaultInstance();
                             break;
                         }
@@ -3427,54 +3426,130 @@
 
             lineView.setLineDirection(arcLineDirection);
 
-            SizedArcContainer sizeWrapper = null;
-            SizedArcContainer.LayoutParams sizedLp =
-                    new SizedArcContainer.LayoutParams(
-                            LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
-            Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
+            @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
             if (sizeForLayout != null) {
-                sizeWrapper = new SizedArcContainer(mUiContext);
-                sizeWrapper.setArcDirection(arcLineDirection);
-                if (sizeForLayout <= 0f) {
-                    Log.w(
-                            TAG,
-                            "ArcLine length's value_for_layout is not a positive value. Element"
-                                    + " won't be visible.");
-                }
-                sizeWrapper.setSweepAngleDegrees(sizeForLayout);
-                sizedLp.setAngularAlignment(
-                        angularAlignmentProtoToAngularAlignment(
-                                length.getAngularAlignmentForLayout()));
-
-                // Also clamp the line to that angle...
                 lineView.setMaxSweepAngleDegrees(sizeForLayout);
             }
-
             View wrappedView =
-                    applyModifiersToArcLayoutView(
-                            lineView, line.getModifiers(), posId, pipelineMaker);
-
-            if (sizeWrapper != null) {
-                sizeWrapper.addView(wrappedView, sizedLp);
-                parentViewWrapper.maybeAddView(sizeWrapper, layoutParams);
-                return new InflatedView(
-                        sizeWrapper,
-                        parentViewWrapper
-                                .getParentProperties()
-                                .applyPendingChildLayoutParams(layoutParams));
-            } else {
-                parentViewWrapper.maybeAddView(wrappedView, layoutParams);
-                return new InflatedView(
-                        wrappedView,
-                        parentViewWrapper
-                                .getParentProperties()
-                                .applyPendingChildLayoutParams(layoutParams));
-            }
+                    applyModifiersToArcLayoutView(lineView, line.getModifiers(), posId,
+                            pipelineMaker);
+            return addLineViewToParentArc(
+                    parentViewWrapper,
+                    wrappedView,
+                    sizeForLayout,
+                    arcLineDirection,
+                    angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()),
+                    arcLayoutWeight);
         } finally {
             lineView.setUpdatesEnabled(true);
         }
     }
 
+    private @Nullable InflatedView inflateDashedArcLine(
+            @NonNull ParentViewWrapper parentViewWrapper,
+            @NonNull DashedArcLine dashedLine,
+            @NonNull String posId,
+            @NonNull Optional<PipelineMaker> pipelineMaker) {
+        float lengthDegrees = max(0, dashedLine.getLength().getValue());
+        int thicknessPx = safeDpToPx(dashedLine.getThickness());
+        if (lengthDegrees == 0 && thicknessPx == 0) {
+            return null;
+        }
+
+        WearDashedArcLineView dashedLineView = new WearDashedArcLineView(mUiContext);
+        dashedLineView.setThickness(thicknessPx);
+
+        if (dashedLine.hasColor()) {
+            handleProp(dashedLine.getColor(), dashedLineView::setColor, posId, pipelineMaker);
+        } else {
+            dashedLineView.setColor(LINE_COLOR_DEFAULT);
+        }
+
+        if (dashedLine.hasLinePattern()) {
+            DashedLinePattern linePattern = dashedLine.getLinePattern();
+            if (linePattern.getGapLocationsCount() > 0) {
+                List<Float> gapLocations = new ArrayList<>();
+                for (DegreesProp degree : linePattern.getGapLocationsList()) {
+                    gapLocations.add(degree.getValue());
+                }
+                dashedLineView.setGapLocations(gapLocations);
+            }
+            dashedLineView.setGapSize(safeDpToPx(linePattern.getGapSize()));
+        }
+
+        ArcDirection arcLineDirection =
+                dashedLine.hasArcDirection()
+                        ? dashedLine.getArcDirection().getValue()
+                        : ArcDirection.UNRECOGNIZED;
+        dashedLineView.setLineDirection(arcLineDirection);
+
+        DegreesProp length = dashedLine.getLength();
+        handleProp(length, dashedLineView::setLineSweepAngleDegrees, posId, pipelineMaker);
+
+        @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
+        if (sizeForLayout != null) {
+            dashedLineView.setMaxSweepAngleDegrees(sizeForLayout);
+        }
+        View wrappedView =
+                applyModifiersToArcLayoutView(
+                        dashedLineView, dashedLine.getModifiers(), posId, pipelineMaker);
+        return addLineViewToParentArc(
+                parentViewWrapper,
+                wrappedView,
+                sizeForLayout,
+                arcLineDirection,
+                angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()),
+                /* arcLayoutWeight= */ 0
+                // Zero weight in ArcLayout means the view should not be stretched.
+        );
+    }
+
+    private InflatedView addLineViewToParentArc(
+            @NonNull ParentViewWrapper parentArc,
+            @NonNull View lineView,
+            @Nullable Float sizeForLayout,
+            @NonNull ArcDirection arcLineDirection,
+            @SizedArcContainer.LayoutParams.AngularAlignment int angularAlignment,
+            float arcLayoutWeight) {
+        SizedArcContainer sizeWrapper = null;
+        SizedArcContainer.LayoutParams sizedLayoutParams =
+                new SizedArcContainer.LayoutParams(LayoutParams.MATCH_PARENT,
+                        LayoutParams.MATCH_PARENT);
+        if (sizeForLayout != null) {
+            sizeWrapper = new SizedArcContainer(mUiContext);
+            sizeWrapper.setArcDirection(arcLineDirection);
+            if (sizeForLayout <= 0f) {
+                Log.w(
+                        TAG,
+                        "Arc Line length's value_for_layout is not a positive value. Element won't"
+                                + " be visible.");
+            }
+            sizeWrapper.setSweepAngleDegrees(sizeForLayout);
+            sizedLayoutParams.setAngularAlignment(angularAlignment);
+        }
+
+        // A WearDashedArcLineView or WearCurvedLineView must always be the same width/height as its
+        // parent, so it can draw the line properly inside of those bounds.
+        ArcLayout.LayoutParams layoutParams = new ArcLayout.LayoutParams(
+                generateDefaultLayoutParams());
+        layoutParams.width = LayoutParams.MATCH_PARENT;
+        layoutParams.height = LayoutParams.MATCH_PARENT;
+        layoutParams.setWeight(arcLayoutWeight);
+
+        if (sizeWrapper != null) {
+            sizeWrapper.addView(lineView, sizedLayoutParams);
+            parentArc.maybeAddView(sizeWrapper, layoutParams);
+            return new InflatedView(
+                    sizeWrapper,
+                    parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams));
+        } else {
+            parentArc.maybeAddView(lineView, layoutParams);
+            return new InflatedView(
+                    lineView,
+                    parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams));
+        }
+    }
+
     // dereference of possibly-null reference childLayoutParams
     @SuppressWarnings("nullness:dereference.of.nullable")
     private @Nullable InflatedView inflateArc(
@@ -3483,7 +3558,7 @@
             String arcPosId,
             boolean includeChildren,
             LayoutInfo.Builder layoutInfoBuilder,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         ArcLayout arcLayout = new ArcLayout(mUiContext);
         int anchorAngleSign = 1;
 
@@ -3929,7 +4004,10 @@
                 break;
 
             case DASHED_LINE:
-                // TODO: b/360314390 - inflate a dashed arc here with WearDashedArcLineView
+                inflatedView =
+                        inflateDashedArcLine(
+                                parentViewWrapper, element.getDashedLine(), nodePosId,
+                                pipelineMaker);
                 break;
 
             case INNER_NOT_SET:
@@ -4001,7 +4079,7 @@
             String nodePosId,
             boolean includeChildren,
             LayoutInfo.Builder layoutInfoBuilder,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         InflatedView inflatedView = null;
         // What is it?
         switch (element.getInnerCase()) {
@@ -4162,7 +4240,7 @@
             StringProp stringProp,
             Consumer<String> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (stringProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker
@@ -4186,7 +4264,7 @@
             DegreesProp degreesProp,
             Consumer<Float> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (degreesProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker
@@ -4205,7 +4283,7 @@
             DpProp dpProp,
             Consumer<Float> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         handleProp(dpProp, consumer, consumer, posId, pipelineMaker);
     }
 
@@ -4214,7 +4292,7 @@
             Consumer<Float> staticValueConsumer,
             Consumer<Float> dynamicValueConsumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (dpProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker
@@ -4233,7 +4311,7 @@
             ColorProp colorProp,
             Consumer<Integer> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (colorProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker.get().addPipelineFor(colorProp, colorProp.getArgb(), posId, consumer);
@@ -4250,7 +4328,7 @@
             BoolProp boolProp,
             Consumer<Boolean> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (boolProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker.get().addPipelineFor(boolProp, boolProp.getValue(), posId, consumer);
@@ -4267,7 +4345,7 @@
             FloatProp floatProp,
             Consumer<Float> consumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         handleProp(floatProp, consumer, consumer, posId, pipelineMaker);
     }
 
@@ -4276,7 +4354,7 @@
             Consumer<Float> staticValueConsumer,
             Consumer<Float> dynamicValueconsumer,
             String posId,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         if (floatProp.hasDynamicValue() && pipelineMaker.isPresent()) {
             try {
                 pipelineMaker
@@ -4499,7 +4577,7 @@
             List<LayoutElement> childElements,
             String parentPosId,
             LayoutInfo.Builder layoutInfoBuilder,
-            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
+            Optional<PipelineMaker> pipelineMaker) {
         int index = FIRST_CHILD_INDEX;
         for (LayoutElement childElement : childElements) {
             String childPosId = ProtoLayoutDiffer.createNodePosId(parentPosId, index++);
@@ -4595,7 +4673,7 @@
         LayoutInfo.Builder layoutInfoBuilder =
                 new LayoutInfo.Builder(prevRenderedMetadata.getLayoutInfo());
         LayoutInfo prevLayoutInfo = prevRenderedMetadata.getLayoutInfo();
-        Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker =
+        Optional<PipelineMaker> pipelineMaker =
                 mDataPipeline.map(
                         p ->
                                 p.newPipelineMaker(
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/SizedArcContainer.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/SizedArcContainer.java
index b0f4843..b75615f 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/SizedArcContainer.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/SizedArcContainer.java
@@ -16,7 +16,7 @@
 
 package androidx.wear.protolayout.renderer.inflater;
 
-import static androidx.wear.protolayout.renderer.inflater.WearCurvedLineView.getSignForClockwise;
+import static androidx.wear.protolayout.renderer.inflater.ArcWidgetHelper.getSignForClockwise;
 
 import android.content.Context;
 import android.content.res.TypedArray;
@@ -244,7 +244,7 @@
         float childSweep = ((ArcLayout.Widget) child).getSweepAngleDegrees();
         float offsetDegrees = (mSweepAngleDegrees - childSweep) / 2;
 
-        int sign = getSignForClockwise(mArcDirection, /* defaultValue= */ 1);
+        int sign = getSignForClockwise(this, mArcDirection, /* defaultValue= */ 1);
 
         switch (alignment) {
             case LayoutParams.ANGULAR_ALIGNMENT_START:
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/TouchDelegateComposite.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/TouchDelegateComposite.java
index dd5da33..c74813f 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/TouchDelegateComposite.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/TouchDelegateComposite.java
@@ -20,7 +20,6 @@
 
 import static java.lang.Math.max;
 
-import android.annotation.SuppressLint;
 import android.graphics.Rect;
 import android.graphics.Region;
 import android.os.Build.VERSION;
@@ -126,7 +125,6 @@
         return eventForwarded;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     @Override
     public AccessibilityNodeInfo.@NonNull TouchDelegateInfo getTouchDelegateInfo() {
         if (VERSION.SDK_INT >= VERSION_CODES.Q && !mDelegates.isEmpty()) {
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedLineView.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedLineView.java
index bb019b0..367acba 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedLineView.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedLineView.java
@@ -17,6 +17,8 @@
 package androidx.wear.protolayout.renderer.inflater;
 
 import static androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.isRtlLayoutDirectionFromLocale;
+import static androidx.wear.protolayout.renderer.inflater.ArcWidgetHelper.getSignForClockwise;
+import static androidx.wear.protolayout.renderer.inflater.ArcWidgetHelper.isPointInsideArcArea;
 
 import static java.lang.Math.min;
 
@@ -183,7 +185,7 @@
                             bounds,
                             clampedSweepAngle,
                             mBasePaint.getStrokeWidth(),
-                            getSignForClockwise(mLineDirection, /* defaultValue= */ 1),
+                            getSignForClockwise(this, mLineDirection, /* defaultValue= */ 1),
                             mBasePaint,
                             mSweepGradientHelper,
                             mCapShadow);
@@ -338,35 +340,8 @@
 
     @Override
     public boolean isPointInsideClickArea(float x, float y) {
-        // Stolen from WearCurvedTextView...
-        float radius2 = min(getWidth(), getHeight()) / 2f - getPaddingTop();
-        float radius1 = radius2 - mBasePaint.getStrokeWidth();
-
-        float dx = x - getWidth() / 2f;
-        float dy = y - getHeight() / 2f;
-
-        float r2 = dx * dx + dy * dy;
-        if (r2 < radius1 * radius1 || r2 > radius2 * radius2) {
-            return false;
-        }
-
-        // Since we are symmetrical on the Y-axis, we can constrain the angle to the x>=0 quadrants.
-        float angle = (float) Math.toDegrees(Math.atan2(Math.abs(dx), -dy));
-        return angle < resolveSweepAngleDegrees() / 2;
-    }
-
-    static int getSignForClockwise(@NonNull ArcDirection arcDirection, int defaultValue) {
-        switch (arcDirection) {
-            case ARC_DIRECTION_CLOCKWISE:
-                return 1;
-            case ARC_DIRECTION_COUNTER_CLOCKWISE:
-                return -1;
-            case ARC_DIRECTION_NORMAL:
-                return isRtlLayoutDirectionFromLocale() ? -1 : 1;
-            case UNRECOGNIZED:
-                return defaultValue;
-        }
-        return defaultValue;
+        return isPointInsideArcArea(
+                this, x, y, mBasePaint.getStrokeWidth(), resolveSweepAngleDegrees());
     }
 
     static class SweepGradientHelper {
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedSpacer.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedSpacer.java
index 1dcc99f..f67adc8 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedSpacer.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearCurvedSpacer.java
@@ -16,6 +16,8 @@
 
 package androidx.wear.protolayout.renderer.inflater;
 
+import static java.lang.Math.min;
+
 import android.content.Context;
 import android.content.res.TypedArray;
 import android.util.AttributeSet;
@@ -37,6 +39,7 @@
     private static final int DEFAULT_THICKNESS_PX = 0;
 
     private float mSweepAngleDegrees;
+    private float mLengthPx = 0;
     private int mThicknessPx;
 
     public WearCurvedSpacer(@NonNull Context context) {
@@ -72,6 +75,27 @@
     }
 
     @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
+
+        // convert length in px to degrees.
+        if (mLengthPx == 0) {
+            return;
+        }
+
+        int size = min(MeasureSpec.getSize(widthMeasureSpec),
+                MeasureSpec.getSize(heightMeasureSpec));
+        if (size == 0) {
+            return;
+        }
+
+        float radius = (size - mThicknessPx) / 2F;
+        // Calculate angle in radian from arc length and radius:
+        // ArcAngleInRadian = ArcLength / Radius
+        mSweepAngleDegrees = (float) Math.toDegrees(mLengthPx / radius);
+    }
+
+    @Override
     public float getSweepAngleDegrees() {
         return mSweepAngleDegrees;
     }
@@ -87,6 +111,14 @@
         this.mSweepAngleDegrees = sweepAngleDegrees;
     }
 
+    /**
+     * Sets the length this spacer, in pixels. If dp length is set, it overrides the degrees
+     * value set by {@link #setSweepAngleDegrees(float)}
+     */
+    public void setLengthPx(float lengthPx) {
+        this.mLengthPx = lengthPx;
+    }
+
     /** Sets the thickness of this spacer, in DP. */
     public void setThickness(int thickness) {
         this.mThicknessPx = thickness;
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearDashedArcLineView.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearDashedArcLineView.java
new file mode 100644
index 0000000..ff9e5ad
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/WearDashedArcLineView.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright 2024 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 androidx.wear.protolayout.renderer.inflater;
+
+import static androidx.wear.protolayout.renderer.inflater.ArcWidgetHelper.getSignForClockwise;
+import static androidx.wear.protolayout.renderer.inflater.ArcWidgetHelper.isPointInsideArcArea;
+import static java.lang.Math.max;
+import static java.lang.Math.min;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Cap;
+import android.graphics.Paint.Style;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.util.Log;
+import android.view.View;
+import androidx.annotation.ColorInt;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcDirection;
+import androidx.wear.widget.ArcLayout.Widget;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A line, drawn inside an arc. It can be divided into segments by specifying gap size and gap
+ * locations.
+ */
+public class WearDashedArcLineView extends View implements Widget {
+  private static final String TAG = "WearDashedArcLineView";
+  private static final int DEFAULT_THICKNESS_PX = 0;
+  @ColorInt private static final int DEFAULT_COLOR = 0xFFFFFFFF;
+
+  /**
+   * The base angle for drawings. The zero angle in Android corresponds to the "3 o clock" position,
+   * while ProtoLayout and ArcLayout use the "12 o clock" position as zero.
+   */
+  private static final float BASE_DRAW_ANGLE_SHIFT = -90f;
+
+  @NonNull private final Paint mPaint;
+  @NonNull private final Paint mScaledPaint;
+  private final ArrayList<Float> mGapLocationsInDegrees = new ArrayList<>();
+  private final ArrayList<LineSegment> mSegments = new ArrayList<>();
+  private ArcDirection mLineDirection = ArcDirection.ARC_DIRECTION_NORMAL;
+  private float mMaxSweepAngleDegrees = 360F;
+  private float mLineSweepAngleDegrees = 0F;
+  private int mGapSizePx = 0;
+  private float mStrokeWidthPx;
+  @Nullable private Path mPath;
+  @Nullable private Path mScaledPath;
+
+  public WearDashedArcLineView(@NonNull Context context) {
+    super(context, null, 0, 0);
+
+    mPaint = new Paint();
+    mPaint.setStyle(Style.STROKE);
+    mPaint.setStrokeCap(Cap.ROUND);
+    mPaint.setColor(DEFAULT_COLOR);
+    mPaint.setStrokeWidth(DEFAULT_THICKNESS_PX);
+    mPaint.setAntiAlias(true);
+
+    mScaledPaint = new Paint(mPaint);
+  }
+
+  private void updatePath() {
+    if (this.getMeasuredWidth() <= 0) {
+      return;
+    }
+
+    mPath = null;
+    mScaledPath = null;
+
+    float lineLength = resolveSweepAngleDegrees();
+    float insetPx = mStrokeWidthPx / 2f;
+    float radius = this.getMeasuredWidth() / 2f - insetPx;
+    // Calculate arc length from angle and radius: ArcLength = Radius * ArcAngleInRadian
+    float dotInDegree = (float) Math.toDegrees(mStrokeWidthPx / radius);
+
+    float startAngleDegrees = mSegments.get(0).startAngleDegrees;
+    if (lineLength <= startAngleDegrees) {
+      return;
+    }
+
+    int arcDirectionSign = getSignForClockwise(this, mLineDirection, /* defaultValue= */ 1);
+
+    RectF rect =
+        new RectF(
+            insetPx,
+            insetPx,
+            this.getMeasuredWidth() - insetPx,
+            this.getMeasuredHeight() - insetPx);
+
+    // The arc needs to be offset by -90 degrees. The ArcContainer will rotate this widget such
+    // that the "12 o clock" position on the canvas is aligned to the center of our requested
+    // angle, but 0 degrees in Android corresponds to the "3 o clock" position.
+    float angleShift = BASE_DRAW_ANGLE_SHIFT - (lineLength / 2f) * arcDirectionSign;
+    for (int i = 0; i < mSegments.size(); i++) {
+      if (mSegments.get(i).startAngleDegrees > lineLength) {
+        return;
+      }
+
+      float segmentArcLength =
+          min(mSegments.get(i).lengthInDegrees, lineLength - mSegments.get(i).startAngleDegrees);
+      float scale = min(segmentArcLength / dotInDegree, 1F);
+      // The android arc line does not count the caps as part of the length.
+      segmentArcLength -= dotInDegree;
+      // For each segment, during the enter transition, we set the stroke width to be equal to
+      // the required arc length, and in the same time, to get the dot displayed stably, we set the
+      // segment to be 0.1 degree to make the dot get displayed.
+      segmentArcLength = max(segmentArcLength, 0.1F);
+
+      float segmentStartAngle = mSegments.get(i).startAngleDegrees + dotInDegree * scale / 2f;
+      if (scale == 1F) {
+        if (mPath == null) {
+          mPath = new Path();
+        }
+        mPath.addArc(
+            rect,
+            segmentStartAngle * arcDirectionSign + angleShift,
+            segmentArcLength * arcDirectionSign);
+      } else {
+        // Apply scale to the stroke width for dot transition.
+        if (mStrokeWidthPx * scale < 1f) { // stroke width is smaller than 1 pixel
+          return;
+        }
+        mScaledPaint.setStrokeWidth(mStrokeWidthPx * scale);
+        mScaledPaint.setAlpha((int) (scale * Color.alpha(mPaint.getColor())));
+        if (mScaledPath == null) {
+          mScaledPath = new Path();
+        }
+        mScaledPath.addArc(
+            rect,
+            segmentStartAngle * arcDirectionSign + angleShift,
+            segmentArcLength * arcDirectionSign);
+      }
+    }
+  }
+
+  private void updateSegments() {
+    if (this.getMeasuredWidth() <= 0) {
+      return;
+    }
+
+    mSegments.clear();
+
+    if (mGapLocationsInDegrees.isEmpty()) {
+      mSegments.add(new LineSegment(0F, 360F));
+      return;
+    }
+
+    float segmentStart = 0;
+    int gapLocIndex = 0;
+    float radius = this.getMeasuredWidth() / 2f - mStrokeWidthPx / 2f;
+    float minSegmentLength = (float) Math.toDegrees(mStrokeWidthPx / radius);
+    float gapInDegrees = (float) Math.toDegrees(mGapSizePx / radius);
+    float halfGapInDegrees = gapInDegrees / 2;
+    if (mGapLocationsInDegrees.get(0) <= halfGapInDegrees) {
+      // start with gap
+      segmentStart = mGapLocationsInDegrees.get(0) + halfGapInDegrees;
+      gapLocIndex = 1;
+    }
+
+    while (gapLocIndex < mGapLocationsInDegrees.size()
+        && mGapLocationsInDegrees.get(gapLocIndex) < 360F + halfGapInDegrees) {
+      float gapLoc = mGapLocationsInDegrees.get(gapLocIndex);
+      float segmentLength = gapLoc - halfGapInDegrees - segmentStart;
+      if (segmentLength > minSegmentLength) {
+        mSegments.add(new LineSegment(segmentStart, segmentLength));
+        segmentStart = gapLoc + halfGapInDegrees;
+      } else {
+        // We could not fit a segment before the gap, ignoring this gap and log an error.
+        Log.e(
+            TAG,
+            "Ignoring the gap at the location "
+                + mGapLocationsInDegrees.get(gapLocIndex)
+                + ", as the arc segment before this gap is shorter than its thickness.");
+      }
+      gapLocIndex++;
+    }
+
+    if (segmentStart < 360F && 360F - segmentStart > minSegmentLength) {
+      mSegments.add(new LineSegment(segmentStart, 360F - segmentStart));
+    }
+  }
+
+  @Override
+  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+    super.onLayout(changed, left, top, right, bottom);
+
+    updateSegments();
+    updatePath();
+  }
+
+  /** Gets the list of each gap's center location in degrees. */
+  @NonNull
+  public List<Float> getGapLocations() {
+    return mGapLocationsInDegrees;
+  }
+
+  /**
+   * Sets the list of each gap's center location in degrees.
+   *
+   * <p>The interval between any two locations could not be shorter than thickness plus gap size.
+   * Any gap for which the distance from its previous gap is shorter than thickness plus gap will be
+   * ignored.
+   */
+  public void setGapLocations(@NonNull List<Float> gapLocations) {
+    mGapLocationsInDegrees.clear();
+    mGapLocationsInDegrees.addAll(gapLocations);
+    Collections.sort(mGapLocationsInDegrees);
+
+    refresh(/* updateSegments= */ true);
+  }
+
+  /** Gets the size of the gap between the segments. */
+  public int getGapSize() {
+    return mGapSizePx;
+  }
+
+  /** Sets the size of the gap between the segments. If not defined, defaults to 0. */
+  public void setGapSize(int lengthInDp) {
+    mGapSizePx = max(0, lengthInDp);
+
+    refresh(/* updateSegments= */ true);
+  }
+
+  /** Sets the length of the line contained within this CurvedLineView. */
+  public void setLineSweepAngleDegrees(float lineLengthDegrees) {
+    this.mLineSweepAngleDegrees = lineLengthDegrees;
+
+    refresh(/* updateSegments= */ false);
+  }
+
+  private float resolveSweepAngleDegrees() {
+    return min(mLineSweepAngleDegrees, mMaxSweepAngleDegrees);
+  }
+
+  private void refresh(boolean updateSegments) {
+    if (updateSegments) {
+      updateSegments();
+    }
+    updatePath();
+    requestLayout();
+    postInvalidate();
+  }
+
+  /** Sets the direction which the line is drawn. */
+  public void setLineDirection(@NonNull ArcDirection direction) {
+    mLineDirection = direction;
+    refresh(/* updateSegments= */ false);
+  }
+
+  /** Gets the direction which the line is drawn. */
+  @NonNull
+  public ArcDirection getLineDirection() {
+    return mLineDirection;
+  }
+
+  /** Returns the sweep angle that this widget is drawn with. */
+  @Override
+  public float getSweepAngleDegrees() {
+    return resolveSweepAngleDegrees();
+  }
+
+  @Override
+  public void setSweepAngleDegrees(float sweepAngleDegrees) {
+    this.mLineSweepAngleDegrees = sweepAngleDegrees;
+  }
+
+  /** Returns the thickness of this widget inside the arc. */
+  @Override
+  public int getThickness() {
+    return (int) mStrokeWidthPx;
+  }
+
+  /** Sets the thickness of this arc in pixels. */
+  public void setThickness(int thickness) {
+    mStrokeWidthPx = max(thickness, 0);
+    mPaint.setStrokeWidth(mStrokeWidthPx);
+    refresh(/* updateSegments= */ true);
+  }
+
+  /**
+   * Check whether the widget contains invalid attributes as a child of ArcLayout, throwing a
+   * Exception if something is wrong. This is important for widgets that can be both standalone or
+   * used inside an ArcLayout, some parameters used when the widget is standalone doesn't make sense
+   * when the widget is inside an ArcLayout.
+   */
+  @Override
+  public void checkInvalidAttributeAsChild() {
+    // Nothing required...
+  }
+
+  /** Gets the maximum sweep angle of the line, in degrees. */
+  public float getMaxSweepAngleDegrees() {
+    return mMaxSweepAngleDegrees;
+  }
+
+  /** Sets the maximum sweep angle of the line, in degrees. */
+  public void setMaxSweepAngleDegrees(float maxSweepAngleDegrees) {
+    this.mMaxSweepAngleDegrees = maxSweepAngleDegrees;
+
+    refresh(/* updateSegments= */ false);
+  }
+
+  /** Returns the color of this arc, in ARGB format. */
+  @ColorInt
+  public int getColor() {
+    return mPaint.getColor();
+  }
+
+  /** Sets the color of this arc, in ARGB format. */
+  public void setColor(@ColorInt int color) {
+    mPaint.setColor(color);
+    mScaledPaint.setColor(color);
+    invalidate();
+  }
+
+  @Override
+  protected void onDraw(@NonNull Canvas canvas) {
+    if (mPath != null) {
+      canvas.drawPath(mPath, mPaint);
+    }
+    if (mScaledPath != null) {
+      canvas.drawPath(mScaledPath, mScaledPaint);
+    }
+  }
+
+  /** Return true when the given point is in the clickable area of the child widget. */
+  @Override
+  public boolean isPointInsideClickArea(float x, float y) {
+    return isPointInsideArcArea(this, x, y, mStrokeWidthPx, resolveSweepAngleDegrees());
+  }
+
+  private static class LineSegment {
+    final float startAngleDegrees;
+    final float lengthInDegrees;
+
+    LineSegment(float startAngleDegrees, float lengthInDegrees) {
+      this.startAngleDegrees = startAngleDegrees;
+      this.lengthInDegrees = lengthInDegrees;
+    }
+  }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
index e4712f9..9322e15 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
@@ -151,6 +151,7 @@
 import androidx.wear.protolayout.proto.LayoutElementProto.Box;
 import androidx.wear.protolayout.proto.LayoutElementProto.ColorFilter;
 import androidx.wear.protolayout.proto.LayoutElementProto.Column;
+import androidx.wear.protolayout.proto.LayoutElementProto.DashedArcLine;
 import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.FontFeatureSetting;
 import androidx.wear.protolayout.proto.LayoutElementProto.FontSetting;
@@ -2335,7 +2336,7 @@
     }
 
     @Test
-    public void inflate_arc_withSpacer() {
+    public void inflate_arc_withSpacerInDegrees() {
         LayoutElement root =
                 LayoutElement.newBuilder()
                         .setArc(
@@ -2359,6 +2360,47 @@
     }
 
     @Test
+    public void inflate_arc_withSpacerInDp() {
+        float containerSize = 100;
+        float thickness = 10;
+        float spacerLength = 20;
+        ContainerDimension containerDimension =
+                ContainerDimension.newBuilder().setLinearDimension(dp(containerSize)).build();
+        Arc.Builder arcBuilder =
+                Arc.newBuilder()
+                        .setAnchorAngle(degrees(0).build())
+                        .addContents(
+                                ArcLayoutElement.newBuilder()
+                                        .setSpacer(
+                                                ArcSpacer.newBuilder()
+                                                        .setAngularLength(
+                                                                AngularDimension.newBuilder().setDp(
+                                                                        dp(spacerLength)))
+                                                        .setThickness(dp(thickness))));
+        LayoutElement root =
+                LayoutElement.newBuilder()
+                        .setBox(
+                                Box.newBuilder()
+                                        .setWidth(containerDimension)
+                                        .setHeight(containerDimension)
+                                        .addContents(LayoutElement.newBuilder().setArc(arcBuilder)))
+                        .build();
+
+        FrameLayout rootLayout = renderer(fingerprintedLayout(root)).inflate();
+
+        float radius = (containerSize - thickness) / 2;
+        float sweepAngle = (float) Math.toDegrees(spacerLength / radius);
+
+        ArcLayout arcLayout = (ArcLayout) ((ViewGroup) rootLayout.getChildAt(0)).getChildAt(0);
+        assertThat(arcLayout.getChildCount()).isEqualTo(1);
+        WearCurvedSpacer spacer = (WearCurvedSpacer) arcLayout.getChildAt(0);
+        assertThat(spacer.getSweepAngleDegrees()).isEqualTo(sweepAngle);
+        // Dimensions are in DP, but the density is currently 1 in the tests, so this is fine:
+        assertThat(spacer.getThickness()).isEqualTo((int) thickness);
+    }
+
+
+    @Test
     public void inflate_arc_withMaxAngleAndWeights() {
         AngularDimension spacerLength =
                 AngularDimension.newBuilder()
@@ -6195,6 +6237,111 @@
                 box, textView, inflatedViewParent.getLeft() - textView.getLeft());
     }
 
+    @Test
+    public void inflate_dashedArcLine_dynamicLength() {
+        AppDataKey<DynamicBuilders.DynamicFloat> keyFoo = new AppDataKey<>("foo");
+        mStateStore.setAppStateEntryValuesProto(
+                ImmutableMap.of(
+                        keyFoo,
+                        DynamicDataValue.newBuilder()
+                                .setFloatVal(FixedFloat.newBuilder().setValue(10F))
+                                .build()));
+
+        DynamicFloat arcLength =
+                DynamicFloat.newBuilder()
+                        .setStateSource(StateFloatSource.newBuilder().setSourceKey("foo").build())
+                        .build();
+
+        DashedArcLine dashedArcLine =
+                DashedArcLine.newBuilder()
+                        // Shorter than 360 degrees, so should be drawn as an arc:
+                        .setLength(degreesDynamic(arcLength, /* valueForLayout= */ 180f))
+                        .setThickness(dp(12))
+                        .build();
+
+        WearDashedArcLineView lineView = inflateDashedArcLine(dashedArcLine);
+        assertThat(lineView.getSweepAngleDegrees()).isEqualTo(10);
+
+        mStateStore.setAppStateEntryValuesProto(
+                ImmutableMap.of(
+                        keyFoo,
+                        DynamicDataValue.newBuilder()
+                                .setFloatVal(FixedFloat.newBuilder().setValue(20F))
+                                .build()));
+
+        assertThat(lineView.getSweepAngleDegrees()).isEqualTo(20);
+    }
+
+    @Test
+    public void inflate_dashedArcLine_usesZeroValueForLayout() {
+        DynamicFloat arcLength =
+                DynamicFloat.newBuilder().setFixed(FixedFloat.newBuilder().setValue(45f)).build();
+
+        DashedArcLine dashedArcLine =
+                DashedArcLine.newBuilder()
+                        .setLength(degreesDynamic(arcLength, /* valueForLayout= */ 0f))
+                        .setThickness(dp(12))
+                        .build();
+
+        WearDashedArcLineView lineView = inflateDashedArcLine(dashedArcLine);
+        expect.that(lineView.getMaxSweepAngleDegrees()).isEqualTo(0f);
+    }
+
+    @Test
+    public void inflate_dashedArcLine_dynamicColor() {
+        AppDataKey<DynamicBuilders.DynamicColor> keyFoo = new AppDataKey<>("foo");
+        mStateStore.setAppStateEntryValuesProto(
+                ImmutableMap.of(
+                        keyFoo,
+                        DynamicDataValue.newBuilder()
+                                .setColorVal(FixedColor.newBuilder().setArgb(Color.CYAN))
+                                .build()));
+
+        DynamicColor arcColor =
+                DynamicColor.newBuilder()
+                        .setStateSource(StateColorSource.newBuilder().setSourceKey("foo").build())
+                        .build();
+
+        DashedArcLine dashedArcLine =
+                DashedArcLine.newBuilder()
+                        // Shorter than 360 degrees, so should be drawn as an arc:
+                        .setLength(degrees(180))
+                        .setColor(ColorProp.newBuilder().setDynamicValue(arcColor))
+                        .setThickness(dp(12))
+                        .build();
+
+        WearDashedArcLineView lineView = inflateDashedArcLine(dashedArcLine);
+        assertThat(lineView.getColor()).isEqualTo(Color.CYAN);
+
+        mStateStore.setAppStateEntryValuesProto(
+                ImmutableMap.of(
+                        keyFoo,
+                        DynamicDataValue.newBuilder()
+                                .setColorVal(FixedColor.newBuilder().setArgb(Color.MAGENTA))
+                                .build()));
+
+        assertThat(lineView.getColor()).isEqualTo(Color.MAGENTA);
+    }
+
+    private WearDashedArcLineView inflateDashedArcLine(DashedArcLine dashedArcLine) {
+        LayoutElement root =
+                LayoutElement.newBuilder()
+                        .setArc(
+                                Arc.newBuilder()
+                                        .addContents(ArcLayoutElement.newBuilder().setDashedLine(
+                                                dashedArcLine)))
+                        .build();
+
+        FrameLayout rootLayout = renderer(fingerprintedLayout(root)).inflate();
+        ArcLayout arcLayout = (ArcLayout) rootLayout.getChildAt(0);
+
+        if (arcLayout.getChildAt(0) instanceof SizedArcContainer) {
+            return (WearDashedArcLineView) ((SizedArcContainer) arcLayout.getChildAt(0)).getChildAt(
+                    0);
+        }
+        return (WearDashedArcLineView) arcLayout.getChildAt(0);
+    }
+
     private void assertSlideInInitialOffset(
             ViewGroup box, TextView textView, float expectedInitialOffset) {
         Animation animation = textView.getAnimation();
diff --git a/wear/protolayout/protolayout-testing/src/main/java/androidx/wear/protolayout/testing/filters.kt b/wear/protolayout/protolayout-testing/src/main/java/androidx/wear/protolayout/testing/filters.kt
index c7b9960..2e024cc 100644
--- a/wear/protolayout/protolayout-testing/src/main/java/androidx/wear/protolayout/testing/filters.kt
+++ b/wear/protolayout/protolayout-testing/src/main/java/androidx/wear/protolayout/testing/filters.kt
@@ -91,7 +91,10 @@
  */
 public fun hasText(value: StringProp): LayoutElementMatcher =
     LayoutElementMatcher("Element text = '$value'") {
-        it is Text && it.text?.toProto() == value.toProto()
+        it is Text &&
+            // TODO: b/375448507 - Add dynamic data evaluation and compare the current string value
+            it.text?.toProto()?.value == value.toProto().value &&
+            it.text?.toProto()?.dynamicValue == value.toProto().dynamicValue
     }
 
 /**
diff --git a/wear/protolayout/protolayout-testing/src/test/java/androidx/wear/protolayout/testing/FiltersTest.kt b/wear/protolayout/protolayout-testing/src/test/java/androidx/wear/protolayout/testing/FiltersTest.kt
index 927a29f..6ca1a48 100644
--- a/wear/protolayout/protolayout-testing/src/test/java/androidx/wear/protolayout/testing/FiltersTest.kt
+++ b/wear/protolayout/protolayout-testing/src/test/java/androidx/wear/protolayout/testing/FiltersTest.kt
@@ -38,7 +38,9 @@
 import androidx.wear.protolayout.ModifiersBuilders.Modifiers
 import androidx.wear.protolayout.ModifiersBuilders.Semantics
 import androidx.wear.protolayout.StateBuilders
+import androidx.wear.protolayout.TypeBuilders.StringLayoutConstraint
 import androidx.wear.protolayout.TypeBuilders.StringProp
+import androidx.wear.protolayout.expression.DynamicBuilders
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -147,6 +149,24 @@
     }
 
     @Test
+    fun hasDynamicText() {
+        val textContent =
+            StringProp.Builder("static content")
+                .setDynamicValue(DynamicBuilders.DynamicString.constant("dynamic content"))
+                .build()
+        val testElement =
+            Text.Builder()
+                .setText(textContent)
+                .setLayoutConstraintsForDynamicText(
+                    StringLayoutConstraint.Builder("static content").build()
+                )
+                .build()
+
+        assertThat(hasText(textContent).matches(testElement)).isTrue()
+        assertThat(hasText("blabla").matches(testElement)).isFalse()
+    }
+
+    @Test
     fun hasImage() {
         val resId = "randomRes"
         val testElement = Image.Builder().setResourceId(resId).build()
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
index 7fba5d7..afd1fc7 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
@@ -4822,8 +4822,8 @@
     }
 
     /**
-     * A dashed arc line that can be placed in an {@link Arc} container. It is an arc line made up
-     * of arc line segments separated by gaps.
+     * A dashed arc line. It is an arc line made up of arc line segments separated by gaps, and can
+     * be placed in an {@link Arc} container.
      */
     @RequiresSchemaVersion(major = 1, minor = 500)
     public static final class DashedArcLine implements ArcLayoutElement {
@@ -4837,10 +4837,6 @@
 
         /**
          * Gets the length of this line in degrees, including gaps.
-         *
-         * <p>When using a dynamic value, make sure to specify the bounding constraints for the
-         * affected layout element through {@code setLayoutConstraintsForDynamicLength
-         * (AngularLayoutConstraint)}, otherwise {@code build()} fails.
          */
         public @Nullable DegreesProp getLength() {
             if (mImpl.hasLength()) {
@@ -4967,7 +4963,8 @@
             public Builder() {}
 
             /**
-             * Sets the length of this line, in degrees. If not defined, defaults to 0.
+             * Sets the length of this line in degrees, including gaps. If not defined, defaults to
+             * 0.
              *
              * <p>When using a dynamic value, make sure to specify the bounding constraints for the
              * affected layout element through {@code setLayoutConstraintsForDynamicLength
@@ -5016,8 +5013,8 @@
             }
 
             /**
-             * Sets the direction in which this line is drawn. If not set, defaults to
-             * ARC_DIRECTION_CLOCKWISE.
+             * Sets the direction in which this line is drawn. If not set, defaults to the {@link
+             * Arc} container's direction where it is placed in.
              */
             @RequiresSchemaVersion(major = 1, minor = 500)
             public @NonNull Builder setArcDirection(@NonNull ArcDirectionProp arcDirection) {
@@ -5028,8 +5025,8 @@
             }
 
             /**
-             * Sets the direction in which this line is drawn. If not set, defaults to
-             * ARC_DIRECTION_CLOCKWISE.
+             * Sets the direction in which this line is drawn. If not set, defaults to the {@link
+             * Arc} container's direction where it is placed in.
              */
             @RequiresSchemaVersion(major = 1, minor = 500)
             public @NonNull Builder setArcDirection(@ArcDirection int arcDirection) {
@@ -5078,7 +5075,10 @@
         }
     }
 
-    /** A dashed line pattern which describes how the dashed arc line is segmented by gaps. */
+    /**
+     * A dashed line pattern which describes how the dashed arc line is segmented by gaps. It
+     * determines the gap size and the gap locations.
+     */
     @RequiresSchemaVersion(major = 1, minor = 500)
     public static final class DashedLinePattern {
         private final LayoutElementProto.DashedLinePattern mImpl;
@@ -5186,8 +5186,8 @@
             /**
              * Sets the list of each gap's center location in degrees.
              *
-             * <p>The interval between any two locations could not be shorter than thickness plus
-             * gap size.
+             * <p>The interval between any two locations must not be shorter than thickness plus gap
+             * size,  otherwise the gap is ignored.
              *
              * <p>Note that calling this method will invalidate the previous call of {@link
              * #setGapInterval}
@@ -5207,7 +5207,8 @@
              * Sets the interval length in degrees between two consecutive gap center locations. The
              * arc line will have arc line segments with equal length.
              *
-             * <p>The interval could not be shorter than thickness plus gap size.
+             * <p>The interval between any two locations must not be shorter than thickness plus gap
+             * size, otherwise the gap is ignored.
              *
              * <p>Note that calling this method will remove all the gap locations set previously
              * with {@link #setGapLocations}
@@ -5217,7 +5218,7 @@
             public @NonNull Builder setGapInterval(float gapIntervalInDegrees) {
                 mImpl.clearGapLocations();
 
-                float gapLocation = gapIntervalInDegrees;
+                float gapLocation = 0;
                 while (gapLocation <= 360F) {
                     addGapLocation(degrees(gapLocation));
                     gapLocation += gapIntervalInDegrees;
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/LayoutElementBuildersTest.java b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/LayoutElementBuildersTest.java
index 1904f9c..dff6d44 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/LayoutElementBuildersTest.java
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/LayoutElementBuildersTest.java
@@ -803,9 +803,10 @@
         LayoutElementProto.DashedLinePattern brush = dashedArcLine.getLinePattern().toProto();
         assertThat(brush.getGapSize().getValue()).isEqualTo(4.5F);
         List<DimensionProto.DegreesProp> gapLocations =brush.getGapLocationsList();
-        assertThat(gapLocations.get(0).getValue()).isEqualTo(111F);
-        assertThat(gapLocations.get(1).getValue()).isEqualTo(222F);
-        assertThat(gapLocations.get(2).getValue()).isEqualTo(333F);
+        assertThat(gapLocations.get(0).getValue()).isEqualTo(0F);
+        assertThat(gapLocations.get(1).getValue()).isEqualTo(111F);
+        assertThat(gapLocations.get(2).getValue()).isEqualTo(222F);
+        assertThat(gapLocations.get(3).getValue()).isEqualTo(333F);
     }
 
     @Test
diff --git a/wear/tiles/tiles-material/build.gradle b/wear/tiles/tiles-material/build.gradle
index 707f6ed..c472f64 100644
--- a/wear/tiles/tiles-material/build.gradle
+++ b/wear/tiles/tiles-material/build.gradle
@@ -63,9 +63,6 @@
     defaultConfig {
         minSdk = 26
     }
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/wear/wear-tiles-material"
-    }
     namespace = "androidx.wear.tiles.material"
 }
 
@@ -75,4 +72,5 @@
     inceptionYear = "2021"
     description = "Material components library for Android Wear Tiles."
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
diff --git a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenTest.java b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenTest.java
index 6825e35..7b87137 100644
--- a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenTest.java
+++ b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenTest.java
@@ -20,6 +20,7 @@
 import static androidx.wear.tiles.material.RunnerUtils.SCREEN_WIDTH;
 import static androidx.wear.tiles.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.tiles.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.tiles.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.tiles.material.TestCasesGenerator.generateTestCases;
 
 import android.content.Context;
@@ -48,7 +49,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-tiles-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public MaterialGoldenTest(
             String expected,
diff --git a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenXLTest.java b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenXLTest.java
index 2115394..19678f5 100644
--- a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenXLTest.java
+++ b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/MaterialGoldenXLTest.java
@@ -21,6 +21,7 @@
 import static androidx.wear.tiles.material.RunnerUtils.SCREEN_WIDTH;
 import static androidx.wear.tiles.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.tiles.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.tiles.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.tiles.material.TestCasesGenerator.XXXL_SCALE_SUFFIX;
 import static androidx.wear.tiles.material.TestCasesGenerator.generateTestCases;
 
@@ -59,7 +60,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-tiles-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public MaterialGoldenXLTest(
             String expected,
diff --git a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/Screenshot.kt b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/Screenshot.kt
new file mode 100644
index 0000000..49c5dc1
--- /dev/null
+++ b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/Screenshot.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.wear.tiles.material
+
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/tiles/tiles-material"
diff --git a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenTest.java b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenTest.java
index 74ace96c..393e674 100644
--- a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenTest.java
+++ b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenTest.java
@@ -20,6 +20,7 @@
 import static androidx.wear.tiles.material.RunnerUtils.SCREEN_WIDTH;
 import static androidx.wear.tiles.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.tiles.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.tiles.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.tiles.material.layouts.TestCasesGenerator.generateTestCases;
 
 import android.content.Context;
@@ -48,7 +49,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-tiles-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public LayoutsGoldenTest(
             String expected,
diff --git a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenXLTest.java b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenXLTest.java
index f05d0b2..9e615c3 100644
--- a/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenXLTest.java
+++ b/wear/tiles/tiles-material/src/androidTest/java/androidx/wear/tiles/material/layouts/LayoutsGoldenXLTest.java
@@ -21,6 +21,7 @@
 import static androidx.wear.tiles.material.RunnerUtils.SCREEN_WIDTH;
 import static androidx.wear.tiles.material.RunnerUtils.runSingleScreenshotTest;
 import static androidx.wear.tiles.material.RunnerUtils.waitForNotificationToDisappears;
+import static androidx.wear.tiles.material.ScreenshotKt.SCREENSHOT_GOLDEN_PATH;
 import static androidx.wear.tiles.material.layouts.TestCasesGenerator.XXXL_SCALE_SUFFIX;
 import static androidx.wear.tiles.material.layouts.TestCasesGenerator.generateTestCases;
 
@@ -59,7 +60,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule mScreenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-tiles-material");
+            new AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH);
 
     public LayoutsGoldenXLTest(
             String expected,
diff --git a/wear/tiles/tiles-renderer/build.gradle b/wear/tiles/tiles-renderer/build.gradle
index d50ba1d..14aedd7 100644
--- a/wear/tiles/tiles-renderer/build.gradle
+++ b/wear/tiles/tiles-renderer/build.gradle
@@ -81,9 +81,6 @@
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
 
-    sourceSets {
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/wear/wear-tiles-renderer"
-    }
     namespace = "androidx.wear.tiles.renderer"
 }
 
@@ -108,5 +105,6 @@
     description = "Android Wear Tiles Renderer components. These components can be used to parse " +
             "and render an already constructed Wear Tile."
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
 
diff --git a/wear/tiles/tiles-renderer/src/androidTest/java/androidx/wear/tiles/renderer/test/TileRendererGoldenTest.java b/wear/tiles/tiles-renderer/src/androidTest/java/androidx/wear/tiles/renderer/test/TileRendererGoldenTest.java
index c8c66c4..a2640ba 100644
--- a/wear/tiles/tiles-renderer/src/androidTest/java/androidx/wear/tiles/renderer/test/TileRendererGoldenTest.java
+++ b/wear/tiles/tiles-renderer/src/androidTest/java/androidx/wear/tiles/renderer/test/TileRendererGoldenTest.java
@@ -120,7 +120,7 @@
 
     @Rule
     public AndroidXScreenshotTestRule screenshotRule =
-            new AndroidXScreenshotTestRule("wear/wear-tiles-renderer");
+            new AndroidXScreenshotTestRule("wear/tiles/tiles-renderer");
 
     // This isn't totally ideal right now. The screenshot tests run on a phone, so emulate some
     // watch dimensions here.
diff --git a/wear/tiles/tiles-tooling/build.gradle b/wear/tiles/tiles-tooling/build.gradle
index 38f2aff..bb2dbec 100644
--- a/wear/tiles/tiles-tooling/build.gradle
+++ b/wear/tiles/tiles-tooling/build.gradle
@@ -33,7 +33,7 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
     androidTestImplementation(libs.guava)
-    androidTestImplementation project(":wear:protolayout:protolayout-material")
+    androidTestImplementation(project(":wear:protolayout:protolayout-material"))
 }
 
 android {
diff --git a/wear/watchface/watchface-client-guava/build.gradle b/wear/watchface/watchface-client-guava/build.gradle
index fecf04a..eba4a21 100644
--- a/wear/watchface/watchface-client-guava/build.gradle
+++ b/wear/watchface/watchface-client-guava/build.gradle
@@ -36,7 +36,7 @@
     api(libs.kotlinCoroutinesGuava)
     implementation("androidx.concurrent:concurrent-futures:1.0.0")
 
-    androidTestImplementation project(":wear:watchface:watchface-samples")
+    androidTestImplementation(project(":wear:watchface:watchface-samples"))
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
diff --git a/wear/watchface/watchface-client/build.gradle b/wear/watchface/watchface-client/build.gradle
index bfb0889..aefdc88 100644
--- a/wear/watchface/watchface-client/build.gradle
+++ b/wear/watchface/watchface-client/build.gradle
@@ -63,9 +63,6 @@
     defaultConfig {
         minSdk = 26
     }
-    sourceSets.androidTest.assets.srcDirs +=
-            project.rootDir.absolutePath + "/../../golden/wear/wear-watchface-client"
-
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
     namespace = "androidx.wear.watchface.client"
@@ -77,4 +74,5 @@
     inceptionYear = "2020"
     description = "Client library for controlling androidx watchfaces"
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/HeadlessWatchFaceClientTest.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/HeadlessWatchFaceClientTest.kt
index bf838f8..52ac483 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/HeadlessWatchFaceClientTest.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/HeadlessWatchFaceClientTest.kt
@@ -239,7 +239,7 @@
 class HeadlessWatchFaceClientScreenshotTest : HeadlessWatchFaceClientTestBase() {
     @get:Rule
     val screenshotRule: AndroidXScreenshotTestRule =
-        AndroidXScreenshotTestRule("wear/wear-watchface-client")
+        AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
 
     private val complications = createTestComplications(context)
 
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/Screenshot.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/Screenshot.kt
new file mode 100644
index 0000000..bd64a51
--- /dev/null
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/Screenshot.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.wear.watchface.client.test
+
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/watchface/watchface-client"
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
index 1ac9517..e75ab0d 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
@@ -1483,7 +1483,7 @@
 class WatchFaceControlClientScreenshotTest : WatchFaceControlClientTestBase() {
     @get:Rule
     val screenshotRule: AndroidXScreenshotTestRule =
-        AndroidXScreenshotTestRule("wear/wear-watchface-client")
+        AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
 
     private val exampleOpenGLWatchFaceComponentName =
         componentOf<ExampleOpenGLBackgroundInitWatchFaceService>()
diff --git a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
index 914dc5b..e250425 100644
--- a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
+++ b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
@@ -4210,7 +4210,6 @@
     return WireSizeAndDimensions(null, drawable.minimumWidth, drawable.minimumHeight)
 }
 
-@SuppressLint("ClassVerificationFailure")
 internal fun Icon.write(dos: DataOutputStream) {
     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
         IconHelper.writeToDataOutputStream(this, dos)
diff --git a/wear/watchface/watchface/build.gradle b/wear/watchface/watchface/build.gradle
index fdb7d3e..8efd472 100644
--- a/wear/watchface/watchface/build.gradle
+++ b/wear/watchface/watchface/build.gradle
@@ -69,10 +69,6 @@
     defaultConfig {
         minSdk = 26
     }
-
-    sourceSets.androidTest.assets.srcDirs +=
-         project.rootDir.absolutePath + "/../../golden/wear/wear-watchface"
-
     // Use Robolectric 4.+
     testOptions.unitTests.includeAndroidResources = true
     namespace = "androidx.wear.watchface"
@@ -85,4 +81,5 @@
     description = "Android Wear Watchface"
     legacyDisableKotlinStrictApiMode = true
     samples(project(":wear:watchface:watchface-samples"))
+    addGoldenImageAssets()
 }
diff --git a/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/Screenshot.kt b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/Screenshot.kt
new file mode 100644
index 0000000..ec3b6cb
--- /dev/null
+++ b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/Screenshot.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.wear.watchface.test
+
+internal const val SCREENSHOT_GOLDEN_PATH = "wear/watchface/watchface"
diff --git a/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
index aadb9c8..d8d5e33 100644
--- a/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
+++ b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
@@ -181,7 +181,7 @@
 @MediumTest
 public class WatchFaceControlServiceTest {
 
-    @get:Rule internal val screenshotRule = AndroidXScreenshotTestRule("wear/wear-watchface")
+    @get:Rule internal val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
 
     private lateinit var instance: IHeadlessWatchFace
 
diff --git a/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
index e204a0f..2dbf636 100644
--- a/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
+++ b/wear/watchface/watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
@@ -182,7 +182,7 @@
 
     @get:Rule
     public val screenshotRule: AndroidXScreenshotTestRule =
-        AndroidXScreenshotTestRule("wear/wear-watchface")
+        AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
 
     private val bitmap = Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ARGB_8888)
     private val canvas = Canvas(bitmap)
diff --git a/wear/wear-input/build.gradle b/wear/wear-input/build.gradle
index 11818b0..3f05ede 100644
--- a/wear/wear-input/build.gradle
+++ b/wear/wear-input/build.gradle
@@ -40,6 +40,7 @@
     testImplementation(libs.testRules)
     testImplementation(libs.robolectric)
     testImplementation(libs.mockitoCore4)
+    testImplementation(fileTree(dir: "../wear_stubs", include: ["com.google.android.wearable-stubs.jar"]))
     testImplementation(project(":wear:wear-input-testing"))
 
     compileOnly(fileTree(dir: "../wear_stubs", include: ["com.google.android.wearable-stubs.jar"]))
diff --git a/wear/wear-input/src/main/java/androidx/wear/input/WearableButtons.java b/wear/wear-input/src/main/java/androidx/wear/input/WearableButtons.java
index 37e98bc..0891331 100644
--- a/wear/wear-input/src/main/java/androidx/wear/input/WearableButtons.java
+++ b/wear/wear-input/src/main/java/androidx/wear/input/WearableButtons.java
@@ -21,7 +21,7 @@
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.RotateDrawable;
 import android.os.Bundle;
-import android.provider.Settings;
+import android.view.Display;
 import android.view.Surface;
 import android.view.WindowManager;
 
@@ -254,33 +254,40 @@
         // Get the screen size for the locationZone
         WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
         Point screenSize = new Point();
-        wm.getDefaultDisplay().getSize(screenSize);
+        Display display = wm.getDefaultDisplay();
+        display.getSize(screenSize);
+        int rotation = display.getRotation();
 
-        if (isLeftyModeEnabled(context)) {
-            // By default, the rotated placement is exactly the opposite.
-            // This may be overridden if there is a remapping of buttons applied as well.
-            float screenRotatedX = screenSize.x - screenLocationX;
-            float screenRotatedY = screenSize.y - screenLocationY;
-
-            if (bundle.containsKey(X_KEY_ROTATED) && bundle.containsKey(Y_KEY_ROTATED)) {
-                screenRotatedX = bundle.getFloat(X_KEY_ROTATED);
-                screenRotatedY = bundle.getFloat(Y_KEY_ROTATED);
-            }
-
-            screenLocationX = screenRotatedX;
-            screenLocationY = screenRotatedY;
+        float screenRotatedX = screenLocationX;
+        float screenRotatedY = screenLocationY;
+        switch (rotation) {
+            case Surface.ROTATION_90:
+                screenLocationX = (screenRotatedY - screenSize.y / 2f) + screenSize.x / 2f;
+                screenLocationY = -(screenRotatedX - screenSize.x / 2f) + screenSize.y / 2f;
+                break;
+            case Surface.ROTATION_180:
+                if (bundle.containsKey(X_KEY_ROTATED) && bundle.containsKey(Y_KEY_ROTATED)) {
+                    // Override if there is a remapping of buttons applied as well.
+                    screenLocationX = bundle.getFloat(X_KEY_ROTATED);
+                    screenLocationY = bundle.getFloat(Y_KEY_ROTATED);
+                } else {
+                    // By default, the rotated placement is exactly the opposite.
+                    screenLocationX = screenSize.x - screenRotatedX;
+                    screenLocationY = screenSize.y - screenRotatedY;
+                }
+                break;
+            case Surface.ROTATION_270:
+                screenLocationX = -(screenRotatedY - screenSize.y / 2f) + screenSize.x / 2f;
+                screenLocationY = (screenRotatedX - screenSize.x / 2f) + screenSize.y / 2f;
+                break;
         }
 
         boolean isRound = context.getResources().getConfiguration().isScreenRound();
-
-        ButtonInfo info =
-                new ButtonInfo(
-                        keycode,
-                        screenLocationX,
-                        screenLocationY,
-                        getLocationZone(isRound, screenSize, screenLocationX, screenLocationY));
-
-        return info;
+        return new ButtonInfo(
+                keycode,
+                screenLocationX,
+                screenLocationY,
+                getLocationZone(isRound, screenSize, screenLocationX, screenLocationY));
     }
 
     /**
@@ -717,14 +724,6 @@
         }
     }
 
-    private static boolean isLeftyModeEnabled(Context context) {
-        return Settings.System.getInt(
-                        context.getContentResolver(),
-                        Settings.System.USER_ROTATION,
-                        Surface.ROTATION_0)
-                == Surface.ROTATION_180;
-    }
-
     /** Metadata for a specific button. */
     public static final class ButtonInfo {
         private final int mKeycode;
diff --git a/wear/wear-input/src/test/java/androidx/wear/input/WearableButtonsTest.java b/wear/wear-input/src/test/java/androidx/wear/input/WearableButtonsTest.java
index 509a8d6..526b1d5 100644
--- a/wear/wear-input/src/test/java/androidx/wear/input/WearableButtonsTest.java
+++ b/wear/wear-input/src/test/java/androidx/wear/input/WearableButtonsTest.java
@@ -22,7 +22,7 @@
 import android.content.Context;
 import android.graphics.Point;
 import android.graphics.drawable.RotateDrawable;
-import android.provider.Settings;
+import android.os.Bundle;
 import android.view.Display;
 import android.view.KeyEvent;
 import android.view.Surface;
@@ -31,6 +31,9 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.wear.input.testing.TestWearableButtonsProvider;
 
+import com.google.android.wearable.input.WearableInputDevice;
+
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.Shadows;
@@ -252,68 +255,189 @@
                 WearableButtons.LOCATION_TOP_LEFT, R.drawable.ic_cc_settings_button_top, -90);
     }
 
+    @SuppressWarnings("deprecation")
     @Test
-    public void testGetButtonsRighty() {
+    public void testGetButtonsRotation0() {
+        Context context = ApplicationProvider.getApplicationContext();
+        WindowManager wm = context.getSystemService(WindowManager.class);
+        Display display = wm.getDefaultDisplay();
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_0);
+
         Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
                 new HashMap<>();
         buttons.put(
                 KeyEvent.KEYCODE_STEM_1,
-                new TestWearableButtonsProvider.TestWearableButtonLocation(1, 2, 3, 4));
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200, /* rotatedX= */ 300, /* rotatedY= */ 400));
 
         TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
         WearableButtons.setWearableButtonsProvider(provider);
 
-        setLeftyModeEnabled(false);
         WearableButtons.ButtonInfo info =
                 WearableButtons.getButtonInfo(
                         ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
         assertNotNull(info);
-        assertEquals(1, info.getX(), 1.0e-7);
-        assertEquals(2, info.getY(), 1.0e-7);
-    }
-
-    @Test
-    public void testGetButtonsLefty() {
-        setLeftyModeEnabled(true);
-        Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
-                new HashMap<>();
-        buttons.put(
-                KeyEvent.KEYCODE_STEM_1,
-                new TestWearableButtonsProvider.TestWearableButtonLocation(1, 2, 3, 4));
-
-        TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
-        WearableButtons.setWearableButtonsProvider(provider);
-        WearableButtons.ButtonInfo info =
-                WearableButtons.getButtonInfo(
-                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
-        assertNotNull(info);
-        assertEquals(3, info.getX(), 1.0e-7);
-        assertEquals(4, info.getY(), 1.0e-7);
+        // expectedX = x, expectedY = y
+        assertEquals(/* expected= */ 100, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ 200, info.getY(), 1.0e-7);
     }
 
     @SuppressWarnings("deprecation")
     @Test
-    public void testGetButtonsLeftyNoData() {
+    public void testGetButtonsRotation90() {
         Context context = ApplicationProvider.getApplicationContext();
-        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+        WindowManager wm = context.getSystemService(WindowManager.class);
         Display display = wm.getDefaultDisplay();
-        Shadows.shadowOf(display).setWidth(480);
-        Shadows.shadowOf(display).setHeight(480);
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_90);
 
-        setLeftyModeEnabled(true);
         Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
                 new HashMap<>();
         buttons.put(
                 KeyEvent.KEYCODE_STEM_1,
-                new TestWearableButtonsProvider.TestWearableButtonLocation(1, 2));
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200, /* rotatedX= */ 300, /* rotatedY= */ 400));
 
         TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
         WearableButtons.setWearableButtonsProvider(provider);
+
         WearableButtons.ButtonInfo info =
-                WearableButtons.getButtonInfo(context, KeyEvent.KEYCODE_STEM_1);
+                WearableButtons.getButtonInfo(
+                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
         assertNotNull(info);
-        assertEquals(479, info.getX(), 1.0e-7); // == 480 - 1
-        assertEquals(478, info.getY(), 1.0e-7); // == 480 - 2
+        // expectedX = (y - height/2) + width/2, expectedY = -(x - width/2) + height/2
+        assertEquals(/* expected= */ 150, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ 450, info.getY(), 1.0e-7);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testGetButtonsRotation180_rotatedXAndYCoordinatesPresent() {
+        float rotatedX = 450;
+        float rotatedY = 550;
+        Context context = ApplicationProvider.getApplicationContext();
+        WindowManager wm = context.getSystemService(WindowManager.class);
+        Display display = wm.getDefaultDisplay();
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_180);
+
+        Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
+                new HashMap<>();
+        buttons.put(
+                KeyEvent.KEYCODE_STEM_1,
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200, rotatedX, rotatedY));
+
+        TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
+        WearableButtons.setWearableButtonsProvider(provider);
+
+        WearableButtons.ButtonInfo info =
+                WearableButtons.getButtonInfo(
+                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
+        assertNotNull(info);
+        // expectedX = rotatedX, expectedY = rotatedY
+        assertEquals(/* expected= */ rotatedX, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ rotatedY, info.getY(), 1.0e-7);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testGetButtonsRotation180_onlyRotatedXCoordinatePresent() {
+        float rotatedX = 450;
+        Context context = ApplicationProvider.getApplicationContext();
+        WindowManager wm = context.getSystemService(WindowManager.class);
+        Display display = wm.getDefaultDisplay();
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_180);
+
+        Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
+                new HashMap<>();
+        buttons.put(
+                KeyEvent.KEYCODE_STEM_1,
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200));
+
+        TestWearableButtonsProvider provider =
+                new TestWearableButtonsProvider(buttons) {
+                    @Override
+                    public @NonNull Bundle getButtonInfo(@NonNull Context context, int keycode) {
+                        Bundle bundle = super.getButtonInfo(context, keycode);
+                        bundle.putFloat(WearableInputDevice.X_KEY_ROTATED, rotatedX);
+                        return bundle;
+                    }
+                };
+        WearableButtons.setWearableButtonsProvider(provider);
+
+        WearableButtons.ButtonInfo info =
+                WearableButtons.getButtonInfo(
+                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
+        assertNotNull(info);
+        // expectedX = width - x, expectedY = height - y
+        assertEquals(/* expected= */ 400, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ 400, info.getY(), 1.0e-7);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testGetButtonsRotation180_rotatedCoordinatesAbsent() {
+        Context context = ApplicationProvider.getApplicationContext();
+        WindowManager wm = context.getSystemService(WindowManager.class);
+        Display display = wm.getDefaultDisplay();
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_180);
+
+        Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
+                new HashMap<>();
+        buttons.put(
+                KeyEvent.KEYCODE_STEM_1,
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200));
+
+        TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
+        WearableButtons.setWearableButtonsProvider(provider);
+
+        WearableButtons.ButtonInfo info =
+                WearableButtons.getButtonInfo(
+                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
+        assertNotNull(info);
+        // expectedX = width - x, expectedY = height - y
+        assertEquals(/* expected= */ 400, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ 400, info.getY(), 1.0e-7);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Test
+    public void testGetButtonsRotation270() {
+        Context context = ApplicationProvider.getApplicationContext();
+        WindowManager wm = context.getSystemService(WindowManager.class);
+        Display display = wm.getDefaultDisplay();
+        Shadows.shadowOf(display).setWidth(/* width= */ 500);
+        Shadows.shadowOf(display).setHeight(/* height= */ 600);
+        Shadows.shadowOf(display).setRotation(Surface.ROTATION_270);
+
+        Map<Integer, TestWearableButtonsProvider.TestWearableButtonLocation> buttons =
+                new HashMap<>();
+        buttons.put(
+                KeyEvent.KEYCODE_STEM_1,
+                new TestWearableButtonsProvider.TestWearableButtonLocation(
+                        /* x= */ 100, /* y= */ 200, /* rotatedX= */ 300, /* rotatedY= */ 400));
+
+        TestWearableButtonsProvider provider = new TestWearableButtonsProvider(buttons);
+        WearableButtons.setWearableButtonsProvider(provider);
+
+        WearableButtons.ButtonInfo info =
+                WearableButtons.getButtonInfo(
+                        ApplicationProvider.getApplicationContext(), KeyEvent.KEYCODE_STEM_1);
+        assertNotNull(info);
+        // expectedX = -(y - height/2) + width/2, expectedY = (x - width/2) + height/2
+        assertEquals(/* expected= */ 350, info.getX(), 1.0e-7);
+        assertEquals(/* expected= */ 150, info.getY(), 1.0e-7);
     }
 
     private void testRotateDrawable(
@@ -328,11 +452,4 @@
         assertEquals(rotateDrawableShadow.getCreatedFromResId(), expectedDrawableId);
         assertEquals(expectedDegreeRotation, rotateDrawable.getFromDegrees(), .001);
     }
-
-    private void setLeftyModeEnabled(boolean enabled) {
-        Settings.System.putInt(
-                ApplicationProvider.getApplicationContext().getContentResolver(),
-                Settings.System.USER_ROTATION,
-                enabled ? Surface.ROTATION_180 : Surface.ROTATION_0);
-    }
 }
diff --git a/wear/wear-phone-interactions/OWNERS b/wear/wear-phone-interactions/OWNERS
index 40741ea..b828954 100644
--- a/wear/wear-phone-interactions/OWNERS
+++ b/wear/wear-phone-interactions/OWNERS
@@ -1,3 +1,4 @@
 # Bug component: 188444
 [email protected]
[email protected]
\ No newline at end of file
[email protected]
[email protected]
\ No newline at end of file
diff --git a/wear/wear-remote-interactions/OWNERS b/wear/wear-remote-interactions/OWNERS
index 40741ea..b828954 100644
--- a/wear/wear-remote-interactions/OWNERS
+++ b/wear/wear-remote-interactions/OWNERS
@@ -1,3 +1,4 @@
 # Bug component: 188444
 [email protected]
[email protected]
\ No newline at end of file
[email protected]
[email protected]
\ No newline at end of file
diff --git a/wear/wear/build.gradle b/wear/wear/build.gradle
index 0af1071..df29901 100644
--- a/wear/wear/build.gradle
+++ b/wear/wear/build.gradle
@@ -56,12 +56,6 @@
     defaultConfig {
         minSdk = 23
     }
-
-    sourceSets {
-        main.res.srcDirs += "src/main/res-public"
-        androidTest.assets.srcDirs += project.rootDir.absolutePath + "/../../golden/wear/wear"
-    }
-
     buildTypes.configureEach {
         consumerProguardFiles "proguard-rules.pro"
     }
@@ -79,4 +73,5 @@
     description = "Android Wear Support UI"
     failOnDeprecationWarnings = false
     legacyDisableKotlinStrictApiMode = true
+    addGoldenImageAssets()
 }
diff --git a/wear/wear/src/main/res-public/values-v24/public_styles.xml b/wear/wear/src/main/res/values-v24/public_styles.xml
similarity index 100%
rename from wear/wear/src/main/res-public/values-v24/public_styles.xml
rename to wear/wear/src/main/res/values-v24/public_styles.xml
diff --git a/wear/wear/src/main/res-public/values/public_attrs.xml b/wear/wear/src/main/res/values/public_attrs.xml
similarity index 100%
rename from wear/wear/src/main/res-public/values/public_attrs.xml
rename to wear/wear/src/main/res/values/public_attrs.xml
diff --git a/webkit/integration-tests/testapp/build.gradle b/webkit/integration-tests/testapp/build.gradle
index 91ab43a..74df165 100644
--- a/webkit/integration-tests/testapp/build.gradle
+++ b/webkit/integration-tests/testapp/build.gradle
@@ -38,6 +38,7 @@
     implementation(libs.guavaAndroid)
     implementation(libs.espressoIdlingNet)
     implementation(libs.espressoIdlingResource)
+    implementation(libs.okhttpMockwebserver)
 
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
diff --git a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/WebStorageCompatActivityTestAppTest.java b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/WebStorageCompatActivityTestAppTest.java
new file mode 100644
index 0000000..ba76199
--- /dev/null
+++ b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/WebStorageCompatActivityTestAppTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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.example.androidx.webkit;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.webkit.WebViewFeature;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class WebStorageCompatActivityTestAppTest {
+
+    @Rule
+    public ActivityScenarioRule<WebStorageCompatActivity> mRule =
+            new ActivityScenarioRule<>(WebStorageCompatActivity.class);
+
+    @Before
+    public void setUp() {
+        WebkitTestHelpers.assumeFeature(WebViewFeature.DELETE_BROWSING_DATA);
+        WebkitTestHelpers.enableJavaScript(R.id.web_storage_webview);
+    }
+
+    @Test
+    public void testWebStorageCompatActivityLoadsCurrentDate() {
+        // This may be flaky right around midnight.
+        SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd");
+        String dateString = format.format(new Date());
+        WebkitTestHelpers.assertHtmlElementContainsText(R.id.web_storage_webview,
+                "timestamp", dateString);
+    }
+}
diff --git a/webkit/integration-tests/testapp/src/main/AndroidManifest.xml b/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
index 45d5d58..b879b40 100644
--- a/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/webkit/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -168,6 +168,9 @@
         <activity
             android:name=".DefaultTrafficStatsTaggingActivity"
             android:exported="true" />
+        <activity
+            android:name=".WebStorageCompatActivity"
+            android:exported="true" />
         <provider
             android:authorities="com.example.androidx.webkit.DropDataProvider"
             android:name="androidx.webkit.DropDataContentProvider"
diff --git a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/MainActivity.java b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/MainActivity.java
index 6bfe6b5..d71ae39 100644
--- a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/MainActivity.java
+++ b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/MainActivity.java
@@ -106,6 +106,9 @@
                 new MenuListView.MenuItem(
                         getResources().getString(R.string.default_trafficstats_tagging_activity),
                         new Intent(activityContext, DefaultTrafficStatsTaggingActivity.class)),
+                new MenuListView.MenuItem(
+                        getResources().getString(R.string.web_storage_activity_title),
+                        new Intent(activityContext, WebStorageCompatActivity.class)),
         };
         listView.setItems(menuItems);
     }
diff --git a/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/WebStorageCompatActivity.java b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/WebStorageCompatActivity.java
new file mode 100644
index 0000000..5505995
--- /dev/null
+++ b/webkit/integration-tests/testapp/src/main/java/com/example/androidx/webkit/WebStorageCompatActivity.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2024 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.example.androidx.webkit;
+
+import static android.webkit.WebSettings.LOAD_DEFAULT;
+import static android.widget.Toast.LENGTH_SHORT;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+import android.webkit.WebStorage;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Button;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.webkit.WebStorageCompat;
+import androidx.webkit.WebViewFeature;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+
+public class WebStorageCompatActivity extends AppCompatActivity {
+
+    private static final String TAG = "WebStorageActivity";
+
+    private WebView mWebView;
+    private MockWebServer mMockWebServer;
+
+    private String mPageUrl;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_web_storage);
+        setTitle(R.string.web_storage_activity_title);
+        WebkitHelpers.appendWebViewVersionToTitle(this);
+        if (!WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
+            WebkitHelpers.showMessageInActivity(WebStorageCompatActivity.this,
+                    R.string.webkit_api_not_available);
+            return;
+        }
+
+
+        mWebView = findViewById(R.id.web_storage_webview);
+        mWebView.getSettings().setCacheMode(LOAD_DEFAULT);
+        mWebView.setWebViewClient(new WebViewClient());
+
+        Button loadButton = findViewById(R.id.web_storage_load_page_button);
+        loadButton.setOnClickListener(this::onLoadButton);
+
+        Button deleteButton = findViewById(R.id.web_storage_delete_data_button);
+        deleteButton.setOnClickListener(this::onDeleteButton);
+
+
+        mMockWebServer = new MockWebServer();
+
+        mMockWebServer.setDispatcher(new Dispatcher() {
+            @Override
+            public MockResponse dispatch(RecordedRequest request) {
+                MockResponse response = new MockResponse();
+                if (!request.getPath().equals("/")) {
+                    response.setResponseCode(400);
+                    return response;
+                }
+
+                try {
+                    String template = readHtmlTemplate();
+                    response.setHeader("Cache-Control", "max-age=604800");
+                    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss z",
+                            Locale.US);
+                    response.setBody(String.format(template, dateFormat.format(new Date())));
+                } catch (IOException e) {
+                    Log.e(TAG, "Error loading html template", e);
+                    response.setResponseCode(500);
+                    response.setBody("Error loading html template");
+                }
+                return response;
+            }
+        });
+
+        mPageUrl = startMockServerAndGetPageUrl();
+        mWebView.loadUrl(mPageUrl);
+    }
+
+    private String startMockServerAndGetPageUrl() {
+        // The mockWebServer accesses networking APIs during startup and URL construction that
+        // are not allowed on the main thread.
+        FutureTask<String> urlTask = new FutureTask<>(() -> {
+            try {
+                mMockWebServer.start();
+                return mMockWebServer.url("/").toString();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        });
+        Executors.newCachedThreadPool().execute(urlTask);
+        try {
+            return urlTask.get(1, TimeUnit.SECONDS);
+        } catch (ExecutionException | InterruptedException | TimeoutException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @NonNull
+    private String readHtmlTemplate() throws IOException {
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(
+                getResources().openRawResource(R.raw.web_storage_html_template)))) {
+            StringBuilder sb = new StringBuilder();
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append("\n");
+            }
+            return sb.toString();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        if (mMockWebServer != null) {
+            try {
+                mMockWebServer.close();
+            } catch (IOException e) {
+                Log.e(TAG, "Error closing mock web server", e);
+            }
+        }
+    }
+
+    private void onLoadButton(View v) {
+        mWebView.loadUrl(mPageUrl);
+    }
+
+    private void onDeleteButton(View v) {
+        WebStorageCompat.deleteBrowsingData(WebStorage.getInstance(), this::onDeletionComplete);
+    }
+
+    private void onDeletionComplete() {
+        Toast.makeText(this, R.string.web_storage_delete_complete, LENGTH_SHORT).show();
+    }
+}
diff --git a/webkit/integration-tests/testapp/src/main/res/layout/activity_web_storage.xml b/webkit/integration-tests/testapp/src/main/res/layout/activity_web_storage.xml
new file mode 100644
index 0000000..1733f5f
--- /dev/null
+++ b/webkit/integration-tests/testapp/src/main/res/layout/activity_web_storage.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2024 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.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <WebView
+        android:id="@+id/web_storage_webview"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        />
+    <Button
+        android:text="@string/web_storage_load_page_button"
+        android:id="@+id/web_storage_load_page_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+    <Button
+        android:text="@string/web_storage_delete_data_button"
+        android:id="@+id/web_storage_delete_data_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/webkit/integration-tests/testapp/src/main/res/raw/web_storage_html_template.html b/webkit/integration-tests/testapp/src/main/res/raw/web_storage_html_template.html
new file mode 100644
index 0000000..ce98c7d
--- /dev/null
+++ b/webkit/integration-tests/testapp/src/main/res/raw/web_storage_html_template.html
@@ -0,0 +1,26 @@
+<!--
+  ~ Copyright 2024 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.
+  -->
+
+<!DOCTYPE html>
+<html>
+    <body>
+        <h1>Test page for cache deletion</h1>
+        <p>This page was last generated at <span id="timestamp">%s</span></p>
+        <p>This page is marked to be cached for 1 year. It will only be updated if the HTTP cache is
+            cleared.
+        </p>
+    </body>
+</html>
\ No newline at end of file
diff --git a/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index 34ef53c..b3e39f0 100644
--- a/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/webkit/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -131,4 +131,11 @@
     <!-- TrafficStats tagging -->
     <string name="default_trafficstats_tagging_activity">Default TrafficStats Tagging</string>
     <string name="default_trafficstats_tagging_unsupported">Default TrafficStats tagging is not supported.</string>
+
+    <!-- WebStorage -->
+    <string name="web_storage_activity_title">WebStorage</string>
+    <string name="web_storage_load_page_button">Reload page</string>
+    <string name="web_storage_delete_data_button">Delete page data</string>
+    <string name="web_storage_delete_complete">Data deletion complete</string>
+
 </resources>
diff --git a/webkit/webkit/lint.xml b/webkit/webkit/lint.xml
index 169aa65..a72b8ce 100644
--- a/webkit/webkit/lint.xml
+++ b/webkit/webkit/lint.xml
@@ -2,8 +2,6 @@
 <lint>
     <!-- Webkit-specific lint rules: -->
 
-    <!-- We cannot cause ClassVerificationFailure in embedding apps -->
-    <issue id="ClassVerificationFailure" severity="fatal" />
     <!-- Developers need to call our code from Kotlin code, so nullness is important.-->
     <issue id="UnknownNullness" severity="fatal" >
         <!-- The boundary interfaces are not annotated, and they represent external chromium code,
diff --git a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/ActivityRule.java b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/ActivityRule.java
index 3f44361..20fac75 100644
--- a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/ActivityRule.java
+++ b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/ActivityRule.java
@@ -16,7 +16,6 @@
 
 package androidx.window.extensions.embedding;
 
-import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
 import android.os.Build;
@@ -51,7 +50,6 @@
     /**
      * Checks if the rule is applicable to the provided activity.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesActivity(@NonNull Activity activity) {
         return mActivityPredicate.test(activity);
@@ -60,7 +58,6 @@
     /**
      * Checks if the rule is applicable to the provided activity intent.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesIntent(@NonNull Intent intent) {
         return mIntentPredicate.test(intent);
diff --git a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPairRule.java b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPairRule.java
index ed93963..b3d0d7f 100644
--- a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPairRule.java
+++ b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPairRule.java
@@ -18,7 +18,6 @@
 
 import static androidx.window.extensions.embedding.SplitAttributes.SplitType.createSplitTypeFromLegacySplitRatio;
 
-import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
 import android.os.Build;
@@ -65,7 +64,6 @@
     /**
      * Checks if the rule is applicable to the provided activities.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesActivityPair(@NonNull Activity primaryActivity,
             @NonNull Activity secondaryActivity) {
@@ -76,7 +74,6 @@
      * Checks if the rule is applicable to the provided primary activity and secondary activity
      * intent.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesActivityIntentPair(@NonNull Activity primaryActivity,
             @NonNull Intent secondaryActivityIntent) {
diff --git a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPlaceholderRule.java b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPlaceholderRule.java
index 25fb7fd6..9269fb4 100644
--- a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPlaceholderRule.java
+++ b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitPlaceholderRule.java
@@ -18,7 +18,6 @@
 
 import static androidx.window.extensions.embedding.SplitAttributes.SplitType.createSplitTypeFromLegacySplitRatio;
 
-import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
 import android.os.Build;
@@ -80,7 +79,6 @@
     /**
      * Checks if the rule is applicable to the provided activity.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesActivity(@NonNull Activity activity) {
         return mActivityPredicate.test(activity);
@@ -89,7 +87,6 @@
     /**
      * Checks if the rule is applicable to the provided activity intent.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean matchesIntent(@NonNull Intent intent) {
         return mIntentPredicate.test(intent);
diff --git a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitRule.java b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitRule.java
index 430eb19..8cc5789 100644
--- a/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitRule.java
+++ b/window/extensions/extensions/src/main/java/androidx/window/extensions/embedding/SplitRule.java
@@ -16,7 +16,6 @@
 
 package androidx.window.extensions.embedding;
 
-import android.annotation.SuppressLint;
 import android.os.Build;
 import android.view.WindowMetrics;
 
@@ -102,7 +101,6 @@
      * @param parentMetrics the {@link WindowMetrics} of the parent window.
      * @return whether the parent window satisfied the {@link SplitRule} requirements.
      */
-    @SuppressLint("ClassVerificationFailure") // Only called by Extensions implementation on device.
     @RequiresApi(api = Build.VERSION_CODES.N)
     public boolean checkParentMetrics(@NonNull WindowMetrics parentMetrics) {
         return mParentWindowMetricsPredicate.test(parentMetrics);
diff --git a/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt b/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
index bff1309..c700fdc 100644
--- a/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
+++ b/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
@@ -169,7 +169,7 @@
 
     @RequiresWindowSdkExtension(OVERLAY_FEATURE_VERSION)
     @OptIn(ExperimentalWindowApi::class)
-    @SuppressLint("NewApi", "ClassVerificationFailure")
+    @SuppressLint("NewApi")
     internal fun translate(
         parentContainerInfo: OEMParentContainerInfo,
     ): ParentContainerInfo {
@@ -653,7 +653,7 @@
                 )
                 .build()
 
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         private fun translateActivityPairPredicates(splitPairFilters: Set<SplitPairFilter>): Any {
             return predicateAdapter.buildPairPredicate(Activity::class, Activity::class) {
                 first: Activity,
@@ -662,7 +662,7 @@
             }
         }
 
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         private fun translateActivityIntentPredicates(splitPairFilters: Set<SplitPairFilter>): Any {
             return predicateAdapter.buildPairPredicate(Activity::class, Intent::class) {
                 first,
@@ -712,21 +712,21 @@
                 attrs.splitType.value != 1.0f &&
                 attrs.layoutDirection in arrayOf(LEFT_TO_RIGHT, RIGHT_TO_LEFT, LOCALE)
 
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         private fun translateActivityPredicates(activityFilters: Set<ActivityFilter>): Any {
             return predicateAdapter.buildPredicate(Activity::class) { activity ->
                 activityFilters.any { filter -> filter.matchesActivity(activity) }
             }
         }
 
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         private fun translateIntentPredicates(activityFilters: Set<ActivityFilter>): Any {
             return predicateAdapter.buildPredicate(Intent::class) { intent ->
                 activityFilters.any { filter -> filter.matchesIntent(intent) }
             }
         }
 
-        @SuppressLint("ClassVerificationFailure", "NewApi")
+        @SuppressLint("NewApi")
         private fun translateParentMetricsPredicate(context: Context, splitRule: SplitRule): Any =
             predicateAdapter.buildPredicate(AndroidWindowMetrics::class) { windowMetrics ->
                 splitRule.checkParentMetrics(context, windowMetrics)
diff --git a/work/integration-tests/testapp/build.gradle b/work/integration-tests/testapp/build.gradle
index 1feec57..6b3212c 100644
--- a/work/integration-tests/testapp/build.gradle
+++ b/work/integration-tests/testapp/build.gradle
@@ -44,6 +44,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     annotationProcessor(project(":room:room-compiler"))
     implementation(project(":room:room-runtime"))
     implementation(project(":room:room-ktx"))
@@ -65,8 +66,3 @@
 tasks.withType(JavaCompile) {
     options.compilerArgs << "-parameters"
 }
-
-androidx {
-    // TODO: b/326456246
-    optOutJSpecify = true
-}
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundLocationWorker.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundLocationWorker.kt
index feecb7e..7df0473 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundLocationWorker.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundLocationWorker.kt
@@ -17,7 +17,6 @@
 package androidx.work.integration.testapp
 
 import android.Manifest.permission.ACCESS_COARSE_LOCATION
-import android.annotation.SuppressLint
 import android.app.Notification
 import android.app.NotificationChannel
 import android.app.NotificationManager
@@ -105,7 +104,6 @@
     }
 
     @RequiresApi(Build.VERSION_CODES.O)
-    @SuppressLint("ClassVerificationFailure")
     private fun createChannel() {
         val id = applicationContext.getString(R.string.channel_id)
         val name = applicationContext.getString(R.string.channel_name)
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundWorker.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundWorker.kt
index d327c6f..caf433e 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundWorker.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ForegroundWorker.kt
@@ -16,7 +16,6 @@
 
 package androidx.work.integration.testapp
 
-import android.annotation.SuppressLint
 import android.app.Notification
 import android.app.NotificationChannel
 import android.app.NotificationManager
@@ -86,7 +85,6 @@
     }
 
     @RequiresApi(Build.VERSION_CODES.O)
-    @SuppressLint("ClassVerificationFailure")
     private fun createChannel() {
         val id = applicationContext.getString(R.string.channel_id)
         val name = applicationContext.getString(R.string.channel_name)
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/InfiniteWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/InfiniteWorker.java
index 9ecb6ab..38acaf6 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/InfiniteWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/InfiniteWorker.java
@@ -18,10 +18,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 public class InfiniteWorker extends Worker {
 
     public InfiniteWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
index 18f87d9..de41686 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
@@ -15,7 +15,6 @@
  */
 package androidx.work.integration.testapp
 
-import android.annotation.SuppressLint
 import android.app.PendingIntent
 import android.app.job.JobInfo
 import android.app.job.JobScheduler
@@ -69,7 +68,6 @@
     private var lastNotificationId = 10
     private val workManager: WorkManager by lazy { WorkManager.getInstance(this) }
 
-    @SuppressLint("ClassVerificationFailure")
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
@@ -443,7 +441,6 @@
     }
 }
 
-@SuppressLint("ClassVerificationFailure")
 private fun enqueueWithNetworkRequest(workManager: WorkManager) {
     if (Build.VERSION.SDK_INT < 21) {
         Log.w(TAG, "Ignoring enqueueWithNetworkRequest on old API levels")
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RecursiveWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RecursiveWorker.java
index 6c32e75..95a254f 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RecursiveWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RecursiveWorker.java
@@ -18,12 +18,13 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.WorkManager;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -37,9 +38,8 @@
         super(context, parameters);
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         OneTimeWorkRequest newRequest = new OneTimeWorkRequest.Builder(RecursiveWorker.class)
                 .addTag(TAG)
                 .setInitialDelay(100, TimeUnit.MILLISECONDS)
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
index bc312d9..2e4b4d7 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
@@ -16,7 +16,6 @@
 
 package androidx.work.integration.testapp
 
-import android.annotation.SuppressLint
 import android.app.Notification
 import android.app.NotificationChannel
 import android.app.NotificationManager
@@ -110,7 +109,6 @@
     }
 
     @RequiresApi(Build.VERSION_CODES.O)
-    @SuppressLint("ClassVerificationFailure")
     private fun createChannel() {
         val id = applicationContext.getString(R.string.channel_id)
         val name = applicationContext.getString(R.string.channel_name)
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RetryWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RetryWorker.java
index c9be7b4..70dfdec 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RetryWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RetryWorker.java
@@ -35,10 +35,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A {@link Worker} which always returns a {@link Result#retry()}.
  */
@@ -51,9 +52,8 @@
         super(context, parameters);
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         Log.e(TAG, "Requesting retry (" + getRunAttemptCount() + ")");
         return Result.retry();
     }
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/SleepWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/SleepWorker.java
index d6122f5..8aed98ac 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/SleepWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/SleepWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A test worker that sleeps.
  */
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestApplication.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestApplication.java
index 54cff00..1d97777 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestApplication.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestApplication.java
@@ -19,9 +19,10 @@
 import android.app.Application;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Configuration;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executors;
 
 /**
@@ -29,9 +30,8 @@
  */
 public class TestApplication extends Application implements Configuration.Provider {
 
-    @NonNull
     @Override
-    public Configuration getWorkManagerConfiguration() {
+    public @NonNull Configuration getWorkManagerConfiguration() {
         return new Configuration.Builder()
                 .setDefaultProcessName(getPackageName())
                 .setTaskExecutor(Executors.newCachedThreadPool())
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestWorker.java
index b9798b6..f8f4ac4 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/TestWorker.java
@@ -17,10 +17,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A Test Worker.
  */
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java
index fb02bd2..cc417a6 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java
@@ -21,12 +21,13 @@
 import android.util.Log;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Date;
 
 /**
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/Image.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/Image.java
index 5bdbf93..329ba9b 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/Image.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/Image.java
@@ -17,19 +17,19 @@
 
 import android.graphics.Bitmap;
 
-import androidx.annotation.NonNull;
 import androidx.room.Entity;
 import androidx.room.Ignore;
 import androidx.room.PrimaryKey;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A POJO for a processed image
  */
 @Entity
 public class Image {
     @PrimaryKey
-    @NonNull
-    public String mOriginalAssetName;
+    public @NonNull String mOriginalAssetName;
 
     public String mProcessedFilePath;
 
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/WordCount.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/WordCount.java
index 78f4d86..361a021 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/WordCount.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/db/WordCount.java
@@ -15,10 +15,11 @@
  */
 package androidx.work.integration.testapp.db;
 
-import androidx.annotation.NonNull;
 import androidx.room.Entity;
 import androidx.room.PrimaryKey;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A POJO for a word and its count.
  */
@@ -26,7 +27,6 @@
 public class WordCount {
 
     @PrimaryKey
-    @NonNull
-    public String mWord;
+    public @NonNull String mWord;
     public int mCount;
 }
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageCleanupWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageCleanupWorker.java
index 1d68a91..f39bb4f 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageCleanupWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageCleanupWorker.java
@@ -20,12 +20,13 @@
 import android.text.TextUtils;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import androidx.work.integration.testapp.db.Image;
 import androidx.work.integration.testapp.db.TestDatabase;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.File;
 import java.util.List;
 
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java
index e4c7cd3..37c317c 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java
@@ -23,7 +23,6 @@
 import android.text.TextUtils;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.Worker;
@@ -31,6 +30,8 @@
 import androidx.work.integration.testapp.db.Image;
 import androidx.work.integration.testapp.db.TestDatabase;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java
index be0cc69..56744d8 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java
@@ -20,7 +20,6 @@
 import android.text.TextUtils;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.Worker;
@@ -28,6 +27,8 @@
 import androidx.work.integration.testapp.db.Image;
 import androidx.work.integration.testapp.db.TestDatabase;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Creates initial {@link Image} entity in db
  */
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java
index 08bde6e..e02fbe4 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java
@@ -18,12 +18,13 @@
 import android.content.Context;
 import android.content.res.AssetManager;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.DataOutputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextReducingWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextReducingWorker.java
index a0c9622..8237e3b 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextReducingWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextReducingWorker.java
@@ -17,13 +17,14 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import androidx.work.integration.testapp.db.TestDatabase;
 import androidx.work.integration.testapp.db.WordCount;
 
+import org.jspecify.annotations.NonNull;
+
 import java.io.DataInputStream;
 import java.io.FileInputStream;
 import java.io.IOException;
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextStartupWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextStartupWorker.java
index f2add48..463a2ef 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextStartupWorker.java
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextStartupWorker.java
@@ -18,11 +18,12 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import androidx.work.integration.testapp.db.TestDatabase;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A Worker that deletes the final results file.
  */
diff --git a/work/work-datatransfer/build.gradle b/work/work-datatransfer/build.gradle
deleted file mode 100644
index 3d6d0a6..0000000
--- a/work/work-datatransfer/build.gradle
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (C) 2023 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.
- */
-
-/**
- * This file was created using the `create_project.py` script located in the
- * `<AndroidX root>/development/project-creator` directory.
- *
- * Please use that script when creating a new project, rather than copying an existing project and
- * modifying its settings.
- */
-import androidx.build.Publish
-
-plugins {
-    id("AndroidXPlugin")
-    id("com.android.library")
-    id("kotlin-android")
-}
-
-android {
-    compileSdk = 35
-    defaultConfig {
-        minSdk = 23
-    }
-    namespace = "androidx.work.datatransfer"
-}
-
-dependencies {
-    api(libs.kotlinStdlib)
-    api(libs.kotlinCoroutinesAndroid)
-    implementation(project(":work:work-runtime-ktx"))
-    androidTestImplementation(libs.junit)
-    androidTestImplementation(libs.testExtJunit)
-    androidTestImplementation(libs.testRunner)
-}
-
-androidx {
-    name = "WorkManager Data Transfer"
-    publish = Publish.SNAPSHOT_ONLY
-    inceptionYear = "2023"
-    description = "Android WorkManager Data Transfer library"
-}
diff --git a/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/ConstraintsTest.kt b/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/ConstraintsTest.kt
deleted file mode 100644
index 4945103..0000000
--- a/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/ConstraintsTest.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.net.NetworkCapabilities
-import android.net.NetworkRequest
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SmallTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-@SmallTest
-class ConstraintsTest {
-
-    @Test
-    fun testDefaultNetworkRequirements() {
-        val constraints1 = Constraints(getDefaultNetworkRequest())
-        val constraints2 = Constraints()
-        assertEquals(constraints1, constraints2)
-
-        val constraints3 =
-            Constraints(
-                NetworkRequest.Builder()
-                    .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                    .build()
-            )
-        assertNotEquals(constraints2, constraints3)
-    }
-
-    @Test
-    fun testEquals() {
-        val constraints1 = Constraints(getDefaultNetworkRequest())
-        val constraints2 = Constraints(getDefaultNetworkRequest())
-        assertEquals(constraints1, constraints2)
-
-        val constraints3 =
-            Constraints(
-                NetworkRequest.Builder()
-                    .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                    .build()
-            )
-        assertNotEquals(constraints2, constraints3)
-    }
-
-    @Test
-    fun testCopyFrom() {
-        val constraints1 = Constraints(getDefaultNetworkRequest())
-        val constraints2 = Constraints.copyFrom(constraints1)
-        assertEquals(constraints1, constraints2)
-
-        val constraints3 =
-            Constraints(
-                NetworkRequest.Builder()
-                    .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                    .build()
-            )
-        assertNotEquals(constraints2, constraints3)
-    }
-
-    private fun getDefaultNetworkRequest(): NetworkRequest {
-        return NetworkRequest.Builder()
-            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-            .build()
-    }
-}
diff --git a/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/UserInitiatedTaskRequestTest.kt b/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/UserInitiatedTaskRequestTest.kt
deleted file mode 100644
index 1a4a410..0000000
--- a/work/work-datatransfer/src/androidTest/java/androidx/work/datatransfer/UserInitiatedTaskRequestTest.kt
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.content.Intent
-import android.net.NetworkCapabilities
-import android.net.NetworkRequest
-import android.os.IBinder
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SmallTest
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotEquals
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Assert.fail
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-@SmallTest
-class UserInitiatedTaskRequestTest {
-
-    @Test
-    fun testDefaultNetworkConstraints() {
-        val request = UserInitiatedTaskRequest(MyTask::class.java)
-        val networkRequest =
-            NetworkRequest.Builder()
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-                .build()
-        assertEquals(request.constraints.networkRequest, networkRequest)
-
-        val networkRequest2 =
-            NetworkRequest.Builder()
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                .build()
-        assertNotEquals(request.constraints.networkRequest, networkRequest2)
-    }
-
-    @Test
-    fun testCustomNetworkConstraints() {
-        val request =
-            UserInitiatedTaskRequest(
-                MyTask::class.java,
-                _constraints =
-                    Constraints(
-                        NetworkRequest.Builder()
-                            .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                            .build()
-                    )
-            )
-        val networkRequest =
-            NetworkRequest.Builder()
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                .build()
-        assertEquals(request.constraints.networkRequest, networkRequest)
-
-        val networkRequest2 =
-            NetworkRequest.Builder()
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-                .build()
-        assertNotEquals(request.constraints.networkRequest, networkRequest2)
-    }
-
-    @Test
-    fun testTags() {
-        val taskClassName = "androidx.work.datatransfer.UserInitiatedTaskRequestTest\$MyTask"
-        var request = UserInitiatedTaskRequest(MyTask::class.java)
-        assertEquals(1, request.tags.size)
-        assertEquals(taskClassName, request.tags.get(0))
-
-        request = UserInitiatedTaskRequest(MyTask::class.java, _tags = mutableListOf("test"))
-        assertEquals(2, request.tags.size)
-        assertTrue(request.tags.contains("test"))
-
-        request =
-            UserInitiatedTaskRequest(MyTask::class.java, _tags = mutableListOf("test", "test2"))
-        assertEquals(3, request.tags.size)
-        assertTrue(request.tags.contains(taskClassName))
-        assertTrue(request.tags.contains("test2"))
-        assertTrue(request.tags.contains("test"))
-    }
-
-    @Test
-    fun testDefaultTransferInfo() {
-        val request = UserInitiatedTaskRequest(MyTask::class.java)
-        assertNull(request.transferInfo)
-    }
-
-    @Test
-    fun testCustomTransferInfo() {
-        var request =
-            UserInitiatedTaskRequest(
-                MyTask::class.java,
-                _transferInfo = TransferInfo(estimatedDownloadBytes = 1000L)
-            )
-        val transferInfo = TransferInfo(0L, 1000L)
-        assertEquals(request.transferInfo, transferInfo)
-
-        request =
-            UserInitiatedTaskRequest(
-                MyTask::class.java,
-                _transferInfo = TransferInfo(estimatedUploadBytes = 1000L)
-            )
-        val transferInfo2 = TransferInfo(1000L, 0L)
-        assertEquals(request.transferInfo, transferInfo2)
-        assertNotEquals(request.transferInfo, transferInfo)
-
-        request =
-            UserInitiatedTaskRequest(MyTask::class.java, _transferInfo = TransferInfo(2000L, 20L))
-        val transferInfo3 = TransferInfo(2000L, 20L)
-        assertEquals(request.transferInfo, transferInfo3)
-    }
-
-    @Test
-    fun testDefaultFallbackPolicy(): Unit = runBlocking {
-        // Default policy FALLBACK_NONE should allow enqueue
-        val request = UserInitiatedTaskRequest(MyTask::class.java)
-        request.enqueue(ApplicationProvider.getApplicationContext())
-    }
-
-    @Test
-    fun testCustomFallbackPolicy(): Unit = runBlocking {
-        val request =
-            UserInitiatedTaskRequest(
-                MyTask::class.java,
-                fallbackPolicy =
-                    UserInitiatedTaskRequest.FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE
-            )
-        try {
-            request.enqueue(ApplicationProvider.getApplicationContext())
-            fail("Expected enqueue to fail without setting a foreground service")
-        } catch (_: IllegalArgumentException) {
-            // expected
-        }
-
-        request.setForegroundService(
-            MyFgs::class.java,
-            UserInitiatedTaskRequest.ForegroundServiceOnTaskFinishPolicy.FOREGROUND_SERVICE_DETACH
-        )
-        request.enqueue(ApplicationProvider.getApplicationContext())
-    }
-
-    private class MyTask :
-        UserInitiatedTask("test_task", ApplicationProvider.getApplicationContext()) {
-        override suspend fun performTask() {
-            // test stub
-        }
-
-        override suspend fun createForegroundInfo(): UitForegroundInfo {
-            // test stub
-            TODO()
-        }
-    }
-
-    private class MyFgs : AbstractUitService() {
-        override fun handleOnStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
-            // test stub
-            return START_STICKY
-        }
-
-        override fun handleOnDestroyCommand() {
-            // test stub
-        }
-
-        override fun onBind(p0: Intent?): IBinder? {
-            return null
-        }
-    }
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/AbstractUitService.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/AbstractUitService.kt
deleted file mode 100644
index dd51382..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/AbstractUitService.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.app.Notification
-import android.app.Service
-import android.content.Intent
-
-/**
- * App developers should migrate their existing foreground service implementation to this new base
- * class instead of [Service].
- *
- * App developers are not supposed to call [Service.stopForeground] (or even [Service.stopService])
- * on their own, otherwise this library will crash unexpectedly.
- */
-abstract class AbstractUitService : Service() {
-
-    /**
-     * This is an equivalent of the [Service.onStartCommand], however, developers should override
-     * this method instead of the [Service.onStartCommand]. Its return value will be honored if
-     * there are no pending/active [UserInitiatedTask]s, otherwise the return value will be ignored.
-     */
-    abstract fun handleOnStartCommand(intent: Intent?, flags: Int, startId: Int): Int
-
-    /**
-     * This is an equivalent of [Service.onDestroy]. Developers should override this method instead.
-     */
-    abstract fun handleOnDestroyCommand()
-
-    /**
-     * Optional method that can be overridden by apps. Apps can implement their own policy for how
-     * multiplexing for task notifications will behave.
-     *
-     * **The default notification policy is FIFO.**
-     */
-    open fun handleTaskNotification(id: Int, notification: Notification) {
-        TODO()
-    }
-
-    final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
-        return super.onStartCommand(intent, flags, startId)
-    }
-
-    final override fun onDestroy() {
-        // TODO: notify UserInitiatedTaskManager to stop all running tasks
-        super.onDestroy()
-    }
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/Constraints.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/Constraints.kt
deleted file mode 100644
index 763e14f..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/Constraints.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.net.NetworkCapabilities
-import android.net.NetworkRequest
-
-/**
- * A specification of the requirements that need to be met before a [UserInitiatedTaskRequest] can
- * run. By default, UserInitiatedTaskRequest only require a network constraint to be specified. By
- * adding additional constraints, you can make sure that work only runs in certain situations - for
- * example, when you have an unmetered network and are charging.
- */
-class Constraints
-constructor(
-    /**
-     * The network request required for the work to run.
-     *
-     * **The default value assumes a requirement of any internet.**
-     */
-    private val requiredNetwork: NetworkRequest = getDefaultNetworkRequest()
-) {
-    val networkRequest: NetworkRequest
-        get() = requiredNetwork
-
-    companion object {
-        /** Copies an existing [Constraints] object. */
-        @JvmStatic
-        fun copyFrom(constraints: Constraints): Constraints {
-            return Constraints(constraints.requiredNetwork)
-        }
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other == null || javaClass != other.javaClass) return false
-        val that = other as Constraints
-        return requiredNetwork == that.requiredNetwork
-    }
-
-    override fun hashCode(): Int {
-        return 31 * requiredNetwork.hashCode()
-    }
-}
-
-fun getDefaultNetworkRequest(): NetworkRequest {
-    return NetworkRequest.Builder()
-        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-        .build()
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/TransferInfo.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/TransferInfo.kt
deleted file mode 100644
index 6769f2c..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/TransferInfo.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-/**
- * A container class holding byte transfer information related to a [UserInitiatedTaskRequest].
- * Specifically, the estimated number of upload or download bytes which are expected to be
- * transferred.
- */
-class TransferInfo(
-    /** The estimated number of bytes to be uploaded, if applicable. */
-    val estimatedUploadBytes: Long = 0L,
-    /** The estimated number of bytes to be downloaded, if applicable. */
-    val estimatedDownloadBytes: Long = 0L
-) {
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other == null || javaClass != other.javaClass) return false
-        val that = other as TransferInfo
-        return estimatedUploadBytes == that.estimatedUploadBytes &&
-            estimatedDownloadBytes == that.estimatedDownloadBytes
-    }
-
-    override fun hashCode(): Int {
-        var result = (estimatedUploadBytes xor (estimatedUploadBytes ushr 32)).toInt()
-        result = 31 * result + (estimatedDownloadBytes xor (estimatedDownloadBytes ushr 32)).toInt()
-        return result
-    }
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UitForegroundInfo.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UitForegroundInfo.kt
deleted file mode 100644
index b3c9f91..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UitForegroundInfo.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.app.Notification
-import android.content.pm.ServiceInfo
-import androidx.work.ForegroundInfo
-
-/**
- * A container class holding information related to the notifications for the
- * [UserInitiatedTaskRequest].
- */
-class UitForegroundInfo(
-    /** The notification id of the notification to be associated with the foreground service. */
-    val notificationId: Int,
-    /** The notification object to be associated with the foreground service. */
-    val notification: Notification,
-    /**
-     * The foreground service type for the foreground service associated with the task request.
-     *
-     * This is not required to be specified on API versions below 29.
-     *
-     * The default type here will be [ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE]. However, on API
-     * versions 34 and above, a different type must be specified otherwise an
-     * [InvalidForegroundServiceTypeException] will be thrown.
-     */
-    @Suppress("DEPRECATION") val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE,
-    /**
-     * Indicates what should be done with the notification after the foreground service is finished.
-     *
-     * **By default, the notification will be removed (see
-     * [TaskEndNotificationPolicy.NOTIFICATION_REMOVE])**
-     */
-    val taskEndNotificationPolicy: TaskEndNotificationPolicy =
-        TaskEndNotificationPolicy.NOTIFICATION_REMOVE
-) {
-    /** Internal container variable pointing to the [ForegroundInfo] object in WorkManager. */
-    private val foregroundInfo: ForegroundInfo =
-        ForegroundInfo(notificationId, notification, fgsType)
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other == null || javaClass != other.javaClass) return false
-        val that = other as UitForegroundInfo
-        return foregroundInfo == that.foregroundInfo &&
-            taskEndNotificationPolicy == that.taskEndNotificationPolicy
-    }
-
-    override fun hashCode(): Int {
-        var result = foregroundInfo.hashCode()
-        result = 31 * result + taskEndNotificationPolicy.hashCode()
-        return result
-    }
-}
-
-enum class TaskEndNotificationPolicy {
-    /**
-     * This indicates that the notification will be removed when the task is finished.
-     *
-     * **This is the default behavior.**
-     */
-    NOTIFICATION_REMOVE,
-    /**
-     * This indicates that the notification will be detached from the foreground service, but not
-     * removed so it can still be modified if needed.
-     */
-    NOTIFICATION_DETACH
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTask.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTask.kt
deleted file mode 100644
index 9165321..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTask.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.app.job.JobParameters
-import android.content.Context
-import java.util.concurrent.CancellationException
-
-/** A class that can perform work asynchronously in UserInitiatedTaskManager. */
-public abstract class UserInitiatedTask(
-    /** A unique identifier for the task. */
-    val name: String,
-    /** The application context. */
-    val appContext: Context
-) {
-
-    /**
-     * Override this method to start your actual data transfer work. This method is called on the
-     * main thread by default.
-     *
-     * If the task is cancelled, the app will get a [CancellationException], with one of the
-     * following messages:
-     * - [JobParameters.STOP_REASON_CONSTRAINT_CONNECTIVITY]
-     * - [JobParameters.STOP_REASON_DEVICE_STATE]
-     * - [JobParameters.STOP_REASON_TIMEOUT]
-     * - [JobParameters.STOP_REASON_USER]
-     */
-    abstract suspend fun performTask()
-
-    /**
-     * Override this method to provide the notification information associated with your work.
-     *
-     * On Android 14 and above, the notification will be delegated to the dedicated JobService.
-     * While on Android 14- devices, the default policy to this API is FIFO: whoever calls this API
-     * later will get posted as a foreground service notification here. The notifications from the
-     * previous calls to this method would be posted as regular notifications (unless they have the
-     * same notification ID).
-     *
-     * To change this behavior on Android 14-, override [AbstractUitService.handleTaskNotification]
-     */
-    abstract suspend fun createForegroundInfo(): UitForegroundInfo
-}
diff --git a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTaskRequest.kt b/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTaskRequest.kt
deleted file mode 100644
index 8b46b3c..0000000
--- a/work/work-datatransfer/src/main/java/androidx/work/datatransfer/UserInitiatedTaskRequest.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * Copyright 2023 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 androidx.work.datatransfer
-
-import android.content.Context
-import java.util.UUID
-
-/**
- * The base class for specifying parameters for network based data transfer work that should be
- * enqueued in DataTransferTaskManager.
- */
-class UserInitiatedTaskRequest
-constructor(
-    private val task: Class<out UserInitiatedTask>,
-    /** [FallbackPolicy] indicating what the library should do on Android 14- devices. */
-    private val fallbackPolicy: FallbackPolicy = FallbackPolicy.FALLBACK_NONE,
-    /**
-     * [Constraints] required for this task to run. The default value assumes a requirement of any
-     * internet.
-     */
-    private val _constraints: Constraints = Constraints(),
-    /**
-     * Sets the appropriate estimated upload/download byte info of the data transfer request via the
-     * [TransferInfo] object.
-     */
-    private val _transferInfo: TransferInfo? = null,
-    /**
-     * A list of tags associated to this work. You can query and cancel work by tags. Tags are
-     * particularly useful for modules or libraries to find and operate on their own work.
-     */
-    private val _tags: MutableList<String> = mutableListOf()
-) {
-    /** The unique identifier associated with this unit of work. */
-    private val id: UUID = UUID.randomUUID()
-    val stringId: String
-        get() = id.toString()
-
-    val constraints: Constraints
-        get() = _constraints
-
-    val transferInfo: TransferInfo?
-        get() = _transferInfo
-
-    val tags: List<String>
-        get() = _tags
-
-    /**
-     * The foreground service which will be used as a fallback solution on Android 14- devices. This
-     * is only used if [FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE] is set.
-     */
-    var service: Class<out AbstractUitService>? = null
-
-    /**
-     * [ForegroundServiceOnTaskFinishPolicy] indicating what should occur when the task is finished.
-     */
-    var onTaskFinishPolicy: ForegroundServiceOnTaskFinishPolicy =
-        ForegroundServiceOnTaskFinishPolicy.FOREGROUND_SERVICE_STOP_FOREGROUND
-
-    init {
-        // Update the list of tags to include the UserInitiatedTask class name if available
-        _tags += task.name
-    }
-
-    /**
-     * Set the [AbstractUitService] service to fallback to on Android 14- devices along with a
-     * [ForegroundServiceOnTaskFinishPolicy] policy which defines what will happen when the task is
-     * finished.
-     *
-     * This is only used if [FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE] is set. If this method
-     * is not called and [FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE] is set, an exception will
-     * be thrown when the task is enqueued.
-     *
-     * Upon scheduling the task request, the library will call [Context.startForegroundService] with
-     * [ACTION_UIT_SCHEDULE] on the given service here. The app needs to call
-     * [android.app.Service.startForeground] within a certain amount of time, otherwise it will
-     * crash with a [android.app.ForegroundServiceDidNotStartInTimeException].
-     */
-    fun setForegroundService(
-        service: Class<out AbstractUitService>,
-        onTaskFinishPolicy: ForegroundServiceOnTaskFinishPolicy =
-            ForegroundServiceOnTaskFinishPolicy.FOREGROUND_SERVICE_STOP_FOREGROUND
-    ) {
-        this.service = service
-        this.onTaskFinishPolicy = onTaskFinishPolicy
-    }
-
-    internal fun getTaskState(): TaskState {
-        return TaskState.TASK_STATE_INVALID // TODO: update impl
-    }
-
-    suspend fun enqueue(@Suppress("UNUSED_PARAMETER") context: Context) {
-        if (
-            this.fallbackPolicy == FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE &&
-                this.service == null
-        ) {
-            throw IllegalArgumentException(
-                "FallbackPolicy.FALLBACK_TO_FOREGROUND_SERVICE is set," +
-                    " but a foreground service has not been set via setForegroundService()."
-            )
-        }
-        // TODO: update impl
-    }
-
-    suspend fun cancel() {
-        // TODO: update impl
-    }
-
-    companion object {
-        const val ACTION_UIT_SCHEDULE =
-            "androidx.work.datatransfer.UserInitiatedTaskRequest.SCHEDULE"
-    }
-
-    enum class FallbackPolicy {
-        /**
-         * Indicates to the library that it should not do anything on Android 14- devices. The
-         * developer will perform the data transfer task on previous versions of Android with their
-         * own logic.
-         *
-         * **This is the default policy.**
-         */
-        FALLBACK_NONE,
-
-        /**
-         * Indicates that the developer will provide an implementation of [AbstractUitService] which
-         * will be used by the library to perform the data transfer work on Android 14- devices.
-         *
-         * _The foreground service fallback will act as a best effort to perform the data transfer
-         * work on Android 14- devices._
-         */
-        FALLBACK_TO_FOREGROUND_SERVICE,
-    }
-
-    enum class ForegroundServiceOnTaskFinishPolicy {
-        /**
-         * This indicates that the foreground service should be stopped when the job is done. This
-         * is the default behavior.
-         */
-        FOREGROUND_SERVICE_STOP_FOREGROUND,
-
-        /**
-         * This indicates that the foreground service should be left as is when the job is done and
-         * the app will manage its lifecycle.
-         */
-        FOREGROUND_SERVICE_DETACH,
-    }
-
-    /** The internal definition of the task states. */
-    internal enum class TaskState {
-        /** Not a valid state. */
-        TASK_STATE_INVALID,
-
-        /**
-         * The task has been scheduled but hasn't been put into execution, it may be waiting for the
-         * constraints. Or, it used to be running, but the constraint are no longer met, so the task
-         * was stopped.
-         */
-        TASK_STATE_SCHEDULED,
-
-        /** The task is being executed. */
-        TASK_STATE_EXECUTING,
-
-        /** The task has finished. */
-        TASK_STATE_FINISHED,
-    }
-}
diff --git a/work/work-gcm/build.gradle b/work/work-gcm/build.gradle
index 4bc3d59..0f26ed1 100644
--- a/work/work-gcm/build.gradle
+++ b/work/work-gcm/build.gradle
@@ -38,7 +38,8 @@
 }
 
 dependencies {
-    api project(":work:work-runtime")
+    api(libs.jspecify)
+    api(project(":work:work-runtime"))
     implementation(libs.gcmNetworkManager)
     annotationProcessor("androidx.room:room-compiler:2.5.0")
     implementation("androidx.room:room-runtime:2.5.0")
@@ -60,6 +61,4 @@
     inceptionYear = "2019"
     description = "Android WorkManager GCMNetworkManager Support"
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
diff --git a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmScheduler.java b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmScheduler.java
index 71f595e..11545db 100644
--- a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmScheduler.java
+++ b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmScheduler.java
@@ -18,7 +18,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Clock;
 import androidx.work.Logger;
 import androidx.work.impl.Scheduler;
@@ -29,6 +28,8 @@
 import com.google.android.gms.gcm.GcmNetworkManager;
 import com.google.android.gms.gcm.Task;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * The {@link androidx.work.WorkManager} scheduler which uses
  * {@link com.google.android.gms.gcm.GcmNetworkManager}.
@@ -50,7 +51,7 @@
     }
 
     @Override
-    public void schedule(@NonNull WorkSpec... workSpecs) {
+    public void schedule(WorkSpec @NonNull ... workSpecs) {
         for (WorkSpec workSpec : workSpecs) {
             Task task = mTaskConverter.convert(workSpec);
             Logger.get().debug(TAG, "Scheduling " + workSpec + "with " + task);
diff --git a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmTaskConverter.java b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmTaskConverter.java
index fb6e1ae..c20fbae9 100644
--- a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmTaskConverter.java
+++ b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/GcmTaskConverter.java
@@ -25,7 +25,6 @@
 import android.os.Build;
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Clock;
 import androidx.work.Constraints;
@@ -36,6 +35,7 @@
 import com.google.android.gms.gcm.OneoffTask;
 import com.google.android.gms.gcm.Task;
 
+import org.jspecify.annotations.NonNull;
 
 /**
  * Converts a {@link androidx.work.impl.model.WorkSpec} to a {@link Task}.
@@ -90,7 +90,7 @@
     }
 
     private static Task.Builder applyConstraints(
-            @NonNull Task.Builder builder,
+            Task.@NonNull Builder builder,
             @NonNull WorkSpec workSpec) {
 
         // Apply defaults
diff --git a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmDispatcher.java b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmDispatcher.java
index faf8487..0dfd345 100644
--- a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmDispatcher.java
+++ b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmDispatcher.java
@@ -21,7 +21,6 @@
 import android.os.PowerManager;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
 import androidx.work.Logger;
 import androidx.work.WorkInfo;
 import androidx.work.impl.ExecutionListener;
@@ -41,6 +40,8 @@
 import com.google.android.gms.gcm.GcmNetworkManager;
 import com.google.android.gms.gcm.TaskParams;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -160,7 +161,7 @@
         }
     }
 
-    private int reschedule(@NonNull final String workSpecId) {
+    private int reschedule(final @NonNull String workSpecId) {
         final WorkDatabase workDatabase = mWorkManagerImpl.getWorkDatabase();
         workDatabase.runInTransaction(new Runnable() {
             @Override
diff --git a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmService.java b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmService.java
index bd99dab..0849695 100644
--- a/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmService.java
+++ b/work/work-gcm/src/main/java/androidx/work/impl/background/gcm/WorkManagerGcmService.java
@@ -18,7 +18,6 @@
 
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
 import androidx.work.Logger;
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.utils.WorkTimer;
@@ -26,6 +25,8 @@
 import com.google.android.gms.gcm.GcmTaskService;
 import com.google.android.gms.gcm.TaskParams;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * The {@link GcmTaskService} responsible for handling requests for executing
  * {@link androidx.work.WorkRequest}s.
diff --git a/work/work-multiprocess/build.gradle b/work/work-multiprocess/build.gradle
index 937a820..8643119 100644
--- a/work/work-multiprocess/build.gradle
+++ b/work/work-multiprocess/build.gradle
@@ -38,7 +38,8 @@
 }
 
 dependencies {
-    api project(":work:work-runtime")
+    api(libs.jspecify)
+    api(project(":work:work-runtime"))
     api(libs.kotlinStdlib)
     api(libs.kotlinCoroutinesAndroid)
     api(libs.guavaListenableFuture)
@@ -60,6 +61,4 @@
     description = "Android WorkManager runtime library"
     failOnDeprecationWarnings = false
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableCallback.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableCallback.java
index ed49d93..fd89df0 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableCallback.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableCallback.java
@@ -18,12 +18,13 @@
 
 import android.os.RemoteException;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -53,8 +54,7 @@
     /**
      * Transforms the result of the {@link ListenableFuture} to a byte array.
      */
-    @NonNull
-    public abstract byte[] toByteArray(@NonNull I result);
+    public abstract byte @NonNull [] toByteArray(@NonNull I result);
 
     /**
      * Dispatches callbacks safely while handling {@link android.os.RemoteException}s.
@@ -90,7 +90,7 @@
          */
         public static void reportSuccess(
                 @NonNull IWorkManagerImplCallback callback,
-                @NonNull byte[] response) {
+                byte @NonNull [] response) {
             try {
                 callback.onSuccess(response);
             } catch (RemoteException exception) {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
index 36eeb32..78185e1 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
@@ -22,7 +22,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Configuration;
 import androidx.work.ForegroundInfo;
@@ -41,6 +40,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.CancellationException;
@@ -90,8 +91,8 @@
 
     @Override
     public void startWork(
-            @NonNull final byte[] request,
-            @NonNull final IWorkManagerImplCallback callback) {
+            final byte @NonNull [] request,
+            final @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableRemoteWorkRequest parcelableRemoteWorkRequest =
                     ParcelConverters.unmarshall(request, ParcelableRemoteWorkRequest.CREATOR);
@@ -144,8 +145,8 @@
 
     @Override
     public void interrupt(
-            @NonNull final byte[] request,
-            @NonNull final IWorkManagerImplCallback callback) {
+            final byte @NonNull [] request,
+            final @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableInterruptRequest interruptRequest =
                     ParcelConverters.unmarshall(request, ParcelableInterruptRequest.CREATOR);
@@ -172,8 +173,8 @@
 
     @Override
     public void getForegroundInfoAsync(
-            @NonNull final byte[] request,
-            @NonNull final IWorkManagerImplCallback callback) {
+            final byte @NonNull [] request,
+            final @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableRemoteWorkRequest parcelableRemoteWorkRequest =
                     ParcelConverters.unmarshall(request, ParcelableRemoteWorkRequest.CREATOR);
@@ -231,8 +232,7 @@
         }
     }
 
-    @NonNull
-    private ListenableFuture<ListenableWorker.Result> executeWorkRequest(
+    private @NonNull ListenableFuture<ListenableWorker.Result> executeWorkRequest(
             @NonNull String workerClassName,
             @NonNull WorkerParameters workerParameters) {
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImplClient.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImplClient.java
index 9884c9f..b75bdf5 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImplClient.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImplClient.java
@@ -25,8 +25,6 @@
 import android.content.ServiceConnection;
 import android.os.IBinder;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Logger;
@@ -34,6 +32,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.concurrent.Executor;
 
 /***
@@ -66,8 +67,7 @@
      * @return a {@link ListenableFuture} of {@link IListenableWorkerImpl} after a
      * {@link ServiceConnection} is established.
      */
-    @NonNull
-    public ListenableFuture<IListenableWorkerImpl> getListenableWorkerImpl(
+    public @NonNull ListenableFuture<IListenableWorkerImpl> getListenableWorkerImpl(
             @NonNull ComponentName component) {
 
         synchronized (mLock) {
@@ -96,8 +96,7 @@
      * Executes a method on an instance of {@link IListenableWorkerImpl} using the instance of
      * {@link RemoteDispatcher}.
      */
-    @NonNull
-    public ListenableFuture<byte[]> execute(
+    public @NonNull ListenableFuture<byte[]> execute(
             @NonNull ComponentName componentName,
             @NonNull RemoteDispatcher<IListenableWorkerImpl> dispatcher) {
 
@@ -109,11 +108,10 @@
      * Executes a method on an instance of {@link IListenableWorkerImpl} using the instance of
      * {@link RemoteDispatcher}
      */
-    @NonNull
     @SuppressLint("LambdaLast")
-    public ListenableFuture<byte[]> execute(
+    public @NonNull ListenableFuture<byte[]> execute(
             @NonNull ListenableFuture<IListenableWorkerImpl> session,
-            @NonNull final RemoteDispatcher<IListenableWorkerImpl> dispatcher) {
+            final @NonNull RemoteDispatcher<IListenableWorkerImpl> dispatcher) {
         return RemoteExecuteKt.execute(mExecutor, session, dispatcher);
     }
 
@@ -132,9 +130,8 @@
     /**
      * @return the {@link ServiceConnection} instance.
      */
-    @Nullable
     @VisibleForTesting
-    public Connection getConnection() {
+    public @Nullable Connection getConnection() {
         return mConnection;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteDispatcher.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteDispatcher.java
index b61a1b7..6c0304e 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteDispatcher.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteDispatcher.java
@@ -18,9 +18,10 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * @param <T> The remote interface subtype that usually implements {@link android.os.IBinder}.
  */
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteForegroundUpdater.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteForegroundUpdater.java
index bcafbb7..e901858 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteForegroundUpdater.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteForegroundUpdater.java
@@ -18,13 +18,14 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.ForegroundInfo;
 import androidx.work.ForegroundUpdater;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -34,9 +35,8 @@
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 public class RemoteForegroundUpdater implements ForegroundUpdater {
-    @NonNull
     @Override
-    public ListenableFuture<Void> setForegroundAsync(
+    public @NonNull ListenableFuture<Void> setForegroundAsync(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull ForegroundInfo foregroundInfo) {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableWorker.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableWorker.java
index b043313..2b6e1f7a 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableWorker.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableWorker.java
@@ -19,7 +19,6 @@
 import android.content.ComponentName;
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
 import androidx.work.Data;
 import androidx.work.ListenableWorker;
@@ -28,6 +27,7 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
 
 /**
  * Is an implementation of a {@link ListenableWorker} that can bind to a remote process.
@@ -71,8 +71,7 @@
     }
 
     @Override
-    @NonNull
-    public final ListenableFuture<Result> startWork() {
+    public final @NonNull ListenableFuture<Result> startWork() {
         String message = "startWork() shouldn't never be called on RemoteListenableWorker";
         return getFailedFuture(message);
     }
@@ -94,8 +93,7 @@
      * @return A {@link ListenableFuture} with the {@code Result} of the computation.  If you
      * cancel this Future, WorkManager will treat this unit of work as a {@code Result#failure()}.
      */
-    @NonNull
-    public abstract ListenableFuture<Result> startRemoteWork();
+    public abstract @NonNull ListenableFuture<Result> startRemoteWork();
 
     private static ListenableFuture<Result> getFailedFuture(@NonNull String message) {
         return CallbackToFutureAdapter.getFuture((completer) -> {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteProgressUpdater.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteProgressUpdater.java
index bdffda7..967a82a 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteProgressUpdater.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteProgressUpdater.java
@@ -18,13 +18,14 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.ProgressUpdater;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -33,9 +34,8 @@
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 public class RemoteProgressUpdater implements ProgressUpdater {
-    @NonNull
     @Override
-    public ListenableFuture<Void> updateProgress(
+    public @NonNull ListenableFuture<Void> updateProgress(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull Data data) {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkContinuationImpl.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkContinuationImpl.java
index 69aea74..b871768 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkContinuationImpl.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkContinuationImpl.java
@@ -18,13 +18,14 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.WorkContinuation;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -44,23 +45,20 @@
         mContinuation = continuation;
     }
 
-    @NonNull
     @Override
     @SuppressLint("EnqueueWork")
-    public RemoteWorkContinuation then(@NonNull List<OneTimeWorkRequest> work) {
+    public @NonNull RemoteWorkContinuation then(@NonNull List<OneTimeWorkRequest> work) {
         return new RemoteWorkContinuationImpl(mClient, mContinuation.then(work));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueue() {
+    public @NonNull ListenableFuture<Void> enqueue() {
         return mClient.enqueue(mContinuation);
     }
 
-    @NonNull
     @Override
     @SuppressLint("EnqueueWork")
-    protected RemoteWorkContinuation combineInternal(
+    protected @NonNull RemoteWorkContinuation combineInternal(
             @NonNull List<RemoteWorkContinuation> continuations) {
 
         int size = continuations.size();
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerClient.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerClient.java
index 74fa0d7..8a397df 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerClient.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerClient.java
@@ -27,8 +27,6 @@
 import android.os.IBinder;
 import android.os.RemoteException;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.arch.core.util.Function;
@@ -58,6 +56,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.UUID;
@@ -113,15 +114,13 @@
         mRunnableScheduler = mWorkManager.getConfiguration().getRunnableScheduler();
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueue(@NonNull WorkRequest request) {
+    public @NonNull ListenableFuture<Void> enqueue(@NonNull WorkRequest request) {
         return enqueue(Collections.singletonList(request));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueue(@NonNull final List<WorkRequest> requests) {
+    public @NonNull ListenableFuture<Void> enqueue(final @NonNull List<WorkRequest> requests) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(
@@ -134,18 +133,16 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueueUniqueWork(
+    public @NonNull ListenableFuture<Void> enqueueUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull List<OneTimeWorkRequest> work) {
         return beginUniqueWork(uniqueWorkName, existingWorkPolicy, work).enqueue();
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueueUniquePeriodicWork(
+    public @NonNull ListenableFuture<Void> enqueueUniquePeriodicWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingPeriodicWorkPolicy existingPeriodicWorkPolicy,
             @NonNull PeriodicWorkRequest periodicWork) {
@@ -164,15 +161,13 @@
         return enqueue(continuation);
     }
 
-    @NonNull
     @Override
-    public RemoteWorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work) {
+    public @NonNull RemoteWorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work) {
         return new RemoteWorkContinuationImpl(this, mWorkManager.beginWith(work));
     }
 
-    @NonNull
     @Override
-    public RemoteWorkContinuation beginUniqueWork(
+    public @NonNull RemoteWorkContinuation beginUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull List<OneTimeWorkRequest> work) {
@@ -180,9 +175,8 @@
                 mWorkManager.beginUniqueWork(uniqueWorkName, existingWorkPolicy, work));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> enqueue(@NonNull final WorkContinuation continuation) {
+    public @NonNull ListenableFuture<Void> enqueue(final @NonNull WorkContinuation continuation) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(@NonNull IWorkManagerImpl iWorkManagerImpl,
@@ -196,9 +190,8 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> cancelWorkById(@NonNull final UUID id) {
+    public @NonNull ListenableFuture<Void> cancelWorkById(final @NonNull UUID id) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(@NonNull IWorkManagerImpl iWorkManagerImpl,
@@ -209,9 +202,8 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> cancelAllWorkByTag(@NonNull final String tag) {
+    public @NonNull ListenableFuture<Void> cancelAllWorkByTag(final @NonNull String tag) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(@NonNull IWorkManagerImpl iWorkManagerImpl,
@@ -222,9 +214,8 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> cancelUniqueWork(@NonNull final String uniqueWorkName) {
+    public @NonNull ListenableFuture<Void> cancelUniqueWork(final @NonNull String uniqueWorkName) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(@NonNull IWorkManagerImpl iWorkManagerImpl,
@@ -235,9 +226,8 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> cancelAllWork() {
+    public @NonNull ListenableFuture<Void> cancelAllWork() {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(@NonNull IWorkManagerImpl iWorkManagerImpl,
@@ -248,9 +238,9 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<List<WorkInfo>> getWorkInfos(@NonNull final WorkQuery workQuery) {
+    public @NonNull ListenableFuture<List<WorkInfo>> getWorkInfos(
+            final @NonNull WorkQuery workQuery) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(
@@ -270,9 +260,9 @@
         }, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> setProgress(@NonNull final UUID id, @NonNull final Data data) {
+    public @NonNull ListenableFuture<Void> setProgress(final @NonNull UUID id,
+            final @NonNull Data data) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
             @Override
             public void execute(
@@ -285,9 +275,8 @@
         return map(result, sVoidMapper, mExecutor);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> setForegroundAsync(
+    public @NonNull ListenableFuture<Void> setForegroundAsync(
             @NonNull String id,
             @NonNull ForegroundInfo foregroundInfo) {
         ListenableFuture<byte[]> result = execute(new RemoteDispatcher<IWorkManagerImpl>() {
@@ -309,9 +298,8 @@
      * @param dispatcher The {@link RemoteDispatcher} instance.
      * @return The {@link ListenableFuture} instance.
      */
-    @NonNull
-    public ListenableFuture<byte[]> execute(
-            @NonNull final RemoteDispatcher<IWorkManagerImpl> dispatcher) {
+    public @NonNull ListenableFuture<byte[]> execute(
+            final @NonNull RemoteDispatcher<IWorkManagerImpl> dispatcher) {
         return execute(getSession(), dispatcher);
     }
 
@@ -319,16 +307,14 @@
      * Gets a handle to an instance of {@link IWorkManagerImpl} by binding to the
      * {@link RemoteWorkManagerService} if necessary.
      */
-    @NonNull
-    public ListenableFuture<IWorkManagerImpl> getSession() {
+    public @NonNull ListenableFuture<IWorkManagerImpl> getSession() {
         return getSession(newIntent(mContext));
     }
 
     /**
      * @return The application {@link Context}.
      */
-    @NonNull
-    public Context getContext() {
+    public @NonNull Context getContext() {
         return mContext;
     }
 
@@ -342,32 +328,28 @@
     /**
      * @return The current {@link Session} in use by {@link RemoteWorkManagerClient}.
      */
-    @Nullable
-    public Session getCurrentSession() {
+    public @Nullable Session getCurrentSession() {
         return mSession;
     }
 
     /**
      * @return the {@link SessionTracker} instance.
      */
-    @NonNull
-    public SessionTracker getSessionTracker() {
+    public @NonNull SessionTracker getSessionTracker() {
         return mSessionTracker;
     }
 
     /**
      * @return The {@link Object} session lock.
      */
-    @NonNull
-    public Object getSessionLock() {
+    public @NonNull Object getSessionLock() {
         return mLock;
     }
 
     /**
      * @return The background {@link Executor} used by {@link RemoteWorkManagerClient}.
      */
-    @NonNull
-    public Executor getExecutor() {
+    public @NonNull Executor getExecutor() {
         return mExecutor;
     }
 
@@ -378,11 +360,10 @@
         return mSessionIndex;
     }
 
-    @NonNull
     @VisibleForTesting
-    ListenableFuture<byte[]> execute(
-            @NonNull final ListenableFuture<IWorkManagerImpl> session,
-            @NonNull final RemoteDispatcher<IWorkManagerImpl> dispatcher) {
+    @NonNull ListenableFuture<byte[]> execute(
+            final @NonNull ListenableFuture<IWorkManagerImpl> session,
+            final @NonNull RemoteDispatcher<IWorkManagerImpl> dispatcher) {
         session.addListener(() -> {
             try {
                 session.get();
@@ -402,9 +383,8 @@
         return future;
     }
 
-    @NonNull
     @VisibleForTesting
-    ListenableFuture<IWorkManagerImpl> getSession(@NonNull Intent intent) {
+    @NonNull ListenableFuture<IWorkManagerImpl> getSession(@NonNull Intent intent) {
         synchronized (mLock) {
             mSessionIndex += 1;
             if (mSession == null) {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerImpl.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerImpl.java
index 6ed8367..0ca6ab1 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerImpl.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerImpl.java
@@ -22,7 +22,6 @@
 import android.content.Context;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Operation;
 import androidx.work.WorkInfo;
@@ -45,6 +44,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.List;
 import java.util.UUID;
 import java.util.concurrent.Executor;
@@ -69,7 +70,7 @@
     @Override
     @MainThread
     public void enqueueWorkRequests(
-            final @NonNull byte[] request,
+            final byte @NonNull [] request,
             final @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableWorkRequests parcelledRequests =
@@ -80,9 +81,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -94,7 +95,7 @@
 
     @Override
     public void enqueueContinuation(
-            final @NonNull byte[] request,
+            final byte @NonNull [] request,
             final @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableWorkContinuationImpl parcelledRequest =
@@ -106,9 +107,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -126,9 +127,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -148,9 +149,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -170,9 +171,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -190,9 +191,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
@@ -203,7 +204,8 @@
     }
 
     @Override
-    public void queryWorkInfo(@NonNull byte[] request, @NonNull IWorkManagerImplCallback callback) {
+    public void queryWorkInfo(byte @NonNull [] request,
+            @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableWorkQuery parcelled =
                     ParcelConverters.unmarshall(request, ParcelableWorkQuery.CREATOR);
@@ -212,9 +214,8 @@
                     mWorkManager.getWorkInfos(parcelled.getWorkQuery());
             final ListenableCallback<List<WorkInfo>> listenableCallback =
                     new ListenableCallback<List<WorkInfo>>(executor, callback, future) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull List<WorkInfo> result) {
+                        public byte @NonNull [] toByteArray(@NonNull List<WorkInfo> result) {
                             ParcelableWorkInfos parcelables = new ParcelableWorkInfos(result);
                             return ParcelConverters.marshall(parcelables);
                         }
@@ -226,7 +227,7 @@
     }
 
     @Override
-    public void setProgress(@NonNull byte[] request, @NonNull IWorkManagerImplCallback callback) {
+    public void setProgress(byte @NonNull [] request, @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableUpdateRequest parcelled =
                     ParcelConverters.unmarshall(request, ParcelableUpdateRequest.CREATOR);
@@ -243,9 +244,8 @@
             );
             final ListenableCallback<Void> listenableCallback =
                     new ListenableCallback<Void>(executor, callback, future) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Void result) {
+                        public byte @NonNull [] toByteArray(@NonNull Void result) {
                             return sEMPTY;
                         }
                     };
@@ -257,7 +257,7 @@
 
     @Override
     public void setForegroundAsync(
-            @NonNull byte[] request,
+            byte @NonNull [] request,
             @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableForegroundRequestInfo parcelled =
@@ -276,9 +276,8 @@
             );
             final ListenableCallback<Void> listenableCallback =
                     new ListenableCallback<Void>(executor, callback, future) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Void result) {
+                        public byte @NonNull [] toByteArray(@NonNull Void result) {
                             return sEMPTY;
                         }
                     };
@@ -289,7 +288,7 @@
     }
 
     @Override
-    public void updateUniquePeriodicWorkRequest(@NonNull String name, @NonNull byte[] request,
+    public void updateUniquePeriodicWorkRequest(@NonNull String name, byte @NonNull [] request,
             @NonNull IWorkManagerImplCallback callback) {
         try {
             ParcelableWorkRequest parcelableWorkRequest = ParcelConverters.unmarshall(request,
@@ -301,9 +300,9 @@
             final ListenableCallback<Operation.State.SUCCESS> listenableCallback =
                     new ListenableCallback<Operation.State.SUCCESS>(executor, callback,
                             operation.getResult()) {
-                        @NonNull
                         @Override
-                        public byte[] toByteArray(@NonNull Operation.State.SUCCESS result) {
+                        public byte @NonNull [] toByteArray(
+                                Operation.State.@NonNull SUCCESS result) {
                             return sEMPTY;
                         }
                     };
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerInfo.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerInfo.java
index 2797baac..a61f399 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerInfo.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerInfo.java
@@ -18,7 +18,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Configuration;
@@ -29,6 +28,8 @@
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Can keep track of WorkManager configuration and schedulers without having to fully
  * initialize {@link androidx.work.WorkManager} in a remote process.
@@ -52,8 +53,7 @@
      * @return an instance of {@link RemoteWorkManagerInfo} which tracks {@link WorkManager}
      * configuration without having to initialize {@link WorkManager}.
      */
-    @NonNull
-    public static RemoteWorkManagerInfo getInstance(@NonNull Context context) {
+    public static @NonNull RemoteWorkManagerInfo getInstance(@NonNull Context context) {
         if (sInstance == null) {
             synchronized (sLock) {
                 if (sInstance == null) {
@@ -102,8 +102,7 @@
      * @return The {@link Configuration} instance which can be used without having to initialize
      * {@link WorkManager}.
      */
-    @NonNull
-    public Configuration getConfiguration() {
+    public @NonNull Configuration getConfiguration() {
         return mConfiguration;
     }
 
@@ -111,8 +110,7 @@
      * @return The {@link TaskExecutor} instance that can be used without having to initialize
      * {@link WorkManager}.
      */
-    @NonNull
-    public TaskExecutor getTaskExecutor() {
+    public @NonNull TaskExecutor getTaskExecutor() {
         return mTaskExecutor;
     }
 
@@ -120,8 +118,7 @@
      * @return The {@link androidx.work.ProgressUpdater} instance that can be use without
      * having to initialize {@link WorkManager}.
      */
-    @NonNull
-    public ProgressUpdater getProgressUpdater() {
+    public @NonNull ProgressUpdater getProgressUpdater() {
         return mProgressUpdater;
     }
 
@@ -129,8 +126,7 @@
      * @return The {@link androidx.work.ForegroundUpdater} instance that can be use without
      * having to initialize {@link WorkManager}.
      */
-    @NonNull
-    public ForegroundUpdater getForegroundUpdater() {
+    public @NonNull ForegroundUpdater getForegroundUpdater() {
         return mForegroundUpdater;
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerService.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerService.java
index cd9263c..243dc5f 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerService.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkManagerService.java
@@ -20,11 +20,12 @@
 import android.content.Intent;
 import android.os.IBinder;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * The {@link android.app.Service} which hosts an implementation of {@link RemoteWorkManager}.
  *
@@ -40,9 +41,8 @@
         mBinder = new RemoteWorkManagerImpl(this);
     }
 
-    @Nullable
     @Override
-    public IBinder onBind(@NonNull Intent intent) {
+    public @Nullable IBinder onBind(@NonNull Intent intent) {
         Logger.get().info(TAG, "Binding to RemoteWorkManager");
         return mBinder;
     }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkerService.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkerService.java
index 4fbf6a6..671c28a 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkerService.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteWorkerService.java
@@ -20,10 +20,11 @@
 import android.content.Intent;
 import android.os.IBinder;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.work.Logger;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * The {@link Service} which hosts an implementation of a {@link androidx.work.ListenableWorker}.
  */
@@ -37,9 +38,8 @@
         mBinder = new ListenableWorkerImpl(this);
     }
 
-    @Nullable
     @Override
-    public IBinder onBind(@NonNull Intent intent) {
+    public @Nullable IBinder onBind(@NonNull Intent intent) {
         Logger.get().info(TAG, "Binding to RemoteWorkerService");
         return mBinder;
     }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelConverters.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelConverters.java
index be233e0..fe1d1f1 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelConverters.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelConverters.java
@@ -19,9 +19,10 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -33,8 +34,7 @@
     /**
      * Marshalls a {@link Parcelable}.
      */
-    @NonNull
-    public static byte[] marshall(@NonNull Parcelable parcelable) {
+    public static byte @NonNull [] marshall(@NonNull Parcelable parcelable) {
         Parcel parcel = Parcel.obtain();
         try {
             parcelable.writeToParcel(parcel, 0 /* flags */);
@@ -47,10 +47,9 @@
     /**
      * Unmarshalls a {@code byte[]} to the {@link T} given a {@link android.os.Parcelable.Creator}.
      */
-    @NonNull
-    public static <T> T unmarshall(
-            @NonNull byte[] array,
-            @NonNull Parcelable.Creator<T> creator) {
+    public static <T> @NonNull T unmarshall(
+            byte @NonNull [] array,
+            Parcelable.@NonNull Creator<T> creator) {
 
         Parcel parcel = Parcel.obtain();
         try {
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
index 949bb5ef..5d1c748 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
@@ -20,9 +20,10 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  *
  */
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableConstraints.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableConstraints.java
index 37eb9b1..fe359e8 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableConstraints.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableConstraints.java
@@ -31,13 +31,14 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Constraints;
 import androidx.work.Constraints.ContentUriTrigger;
 import androidx.work.NetworkType;
 import androidx.work.impl.utils.NetworkRequest28;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
@@ -162,8 +163,7 @@
         }
     }
 
-    @NonNull
-    public Constraints getConstraints() {
+    public @NonNull Constraints getConstraints() {
         return mConstraints;
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundRequestInfo.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundRequestInfo.java
index 5915a5b..9eea428 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundRequestInfo.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundRequestInfo.java
@@ -21,10 +21,11 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.ForegroundInfo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * ForegroundInfo but parcelable.
  *
@@ -69,13 +70,11 @@
             };
 
 
-    @NonNull
-    public ForegroundInfo getForegroundInfo() {
+    public @NonNull ForegroundInfo getForegroundInfo() {
         return mForegroundInfo;
     }
 
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRemoteWorkRequest.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRemoteWorkRequest.java
index 9609b54..4a54688 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRemoteWorkRequest.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRemoteWorkRequest.java
@@ -20,10 +20,11 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Everything you need to run a {@link androidx.work.multiprocess.RemoteListenableWorker}.
  *
@@ -79,13 +80,11 @@
         mParcelableWorkerParameters.writeToParcel(parcel, flags);
     }
 
-    @NonNull
-    public String getWorkerClassName() {
+    public @NonNull String getWorkerClassName() {
         return mWorkerClassName;
     }
 
-    @NonNull
-    public ParcelableWorkerParameters getParcelableWorkerParameters() {
+    public @NonNull ParcelableWorkerParameters getParcelableWorkerParameters() {
         return mParcelableWorkerParameters;
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableResult.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableResult.java
index c8340d5..a3bd73c 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableResult.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableResult.java
@@ -20,11 +20,12 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.ListenableWorker;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * {@link androidx.work.ListenableWorker.Result}, but parcelable.
  *
@@ -34,7 +35,7 @@
 public class ParcelableResult implements Parcelable {
     private final ListenableWorker.Result mResult;
 
-    public ParcelableResult(@NonNull ListenableWorker.Result result) {
+    public ParcelableResult(ListenableWorker.@NonNull Result result) {
         mResult = result;
     }
 
@@ -49,8 +50,7 @@
     public static final Creator<ParcelableResult> CREATOR =
             new Creator<ParcelableResult>() {
                 @Override
-                @NonNull
-                public ParcelableResult createFromParcel(Parcel in) {
+                public @NonNull ParcelableResult createFromParcel(Parcel in) {
                     return new ParcelableResult(in);
                 }
 
@@ -76,8 +76,7 @@
         parcelableOutputData.writeToParcel(parcel, flags);
     }
 
-    @NonNull
-    public ListenableWorker.Result getResult() {
+    public ListenableWorker.@NonNull Result getResult() {
         return mResult;
     }
 
@@ -94,8 +93,8 @@
         }
     }
 
-    @NonNull
-    private static ListenableWorker.Result intToResultType(int resultType, @NonNull Data data) {
+    private static ListenableWorker.@NonNull Result intToResultType(int resultType,
+            @NonNull Data data) {
         ListenableWorker.Result result = null;
         if (resultType == 1) {
             result = ListenableWorker.Result.retry();
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRuntimeExtras.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRuntimeExtras.java
index 4180a34..3390d7f 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRuntimeExtras.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableRuntimeExtras.java
@@ -26,11 +26,12 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -43,7 +44,7 @@
 public class ParcelableRuntimeExtras implements Parcelable {
     private WorkerParameters.RuntimeExtras mRuntimeExtras;
 
-    public ParcelableRuntimeExtras(@NonNull WorkerParameters.RuntimeExtras runtimeExtras) {
+    public ParcelableRuntimeExtras(WorkerParameters.@NonNull RuntimeExtras runtimeExtras) {
         mRuntimeExtras = runtimeExtras;
     }
 
@@ -89,8 +90,7 @@
     public static final Creator<ParcelableRuntimeExtras> CREATOR =
             new Creator<ParcelableRuntimeExtras>() {
                 @Override
-                @NonNull
-                public ParcelableRuntimeExtras createFromParcel(Parcel in) {
+                public @NonNull ParcelableRuntimeExtras createFromParcel(Parcel in) {
                     return new ParcelableRuntimeExtras(in);
                 }
 
@@ -144,8 +144,7 @@
         }
     }
 
-    @NonNull
-    public WorkerParameters.RuntimeExtras getRuntimeExtras() {
+    public WorkerParameters.@NonNull RuntimeExtras getRuntimeExtras() {
         return mRuntimeExtras;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableUpdateRequest.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableUpdateRequest.java
index f484a55..2f9fac7 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableUpdateRequest.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableUpdateRequest.java
@@ -20,10 +20,11 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -63,13 +64,11 @@
                 }
             };
 
-    @NonNull
-    public String getId() {
+    public @NonNull String getId() {
         return mId;
     }
 
-    @NonNull
-    public Data getData() {
+    public @NonNull Data getData() {
         return mParcelableData.getData();
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkContinuationImpl.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkContinuationImpl.java
index 4881dd1..ce58af8 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkContinuationImpl.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkContinuationImpl.java
@@ -24,8 +24,6 @@
 import android.os.Parcelable;
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.work.ExistingWorkPolicy;
 import androidx.work.WorkRequest;
@@ -33,6 +31,9 @@
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.WorkRequestHolder;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -56,8 +57,7 @@
         mInfo = info;
     }
 
-    @NonNull
-    public WorkContinuationImplInfo getInfo() {
+    public @NonNull WorkContinuationImplInfo getInfo() {
         return mInfo;
     }
 
@@ -159,8 +159,8 @@
      * @param workManager The {@link  WorkManagerImpl} instance.
      * @return The {@link WorkContinuationImpl} instance
      */
-    @NonNull
-    public WorkContinuationImpl toWorkContinuationImpl(@NonNull WorkManagerImpl workManager) {
+    public @NonNull WorkContinuationImpl toWorkContinuationImpl(
+            @NonNull WorkManagerImpl workManager) {
         return mInfo.toWorkContinuationImpl(workManager);
     }
 
@@ -199,23 +199,19 @@
             mParents = parents;
         }
 
-        @Nullable
-        public String getName() {
+        public @Nullable String getName() {
             return mName;
         }
 
-        @NonNull
-        public ExistingWorkPolicy getExistingWorkPolicy() {
+        public @NonNull ExistingWorkPolicy getExistingWorkPolicy() {
             return mWorkPolicy;
         }
 
-        @NonNull
-        public List<? extends WorkRequest> getWork() {
+        public @NonNull List<? extends WorkRequest> getWork() {
             return mRequests;
         }
 
-        @Nullable
-        public List<WorkContinuationImplInfo> getParentInfos() {
+        public @Nullable List<WorkContinuationImplInfo> getParentInfos() {
             return mParents;
         }
 
@@ -226,8 +222,8 @@
          * @param workManager The {@link  WorkManagerImpl} instance.
          * @return The {@link WorkContinuationImpl} instance
          */
-        @NonNull
-        public WorkContinuationImpl toWorkContinuationImpl(@NonNull WorkManagerImpl workManager) {
+        public @NonNull WorkContinuationImpl toWorkContinuationImpl(
+                @NonNull WorkManagerImpl workManager) {
             return new WorkContinuationImpl(
                     workManager,
                     getName(),
@@ -237,8 +233,7 @@
             );
         }
 
-        @Nullable
-        private static List<WorkContinuationImpl> parents(
+        private static @Nullable List<WorkContinuationImpl> parents(
                 @NonNull WorkManagerImpl workManager,
                 @Nullable List<WorkContinuationImplInfo> parentInfos) {
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfo.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfo.java
index 187ca90..5afadaf 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfo.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfo.java
@@ -23,11 +23,12 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.WorkInfo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
@@ -71,8 +72,7 @@
         mWorkInfo = new WorkInfo(id, state, tags, output, progress, runAttemptCount, generation);
     }
 
-    @NonNull
-    public WorkInfo getWorkInfo() {
+    public @NonNull WorkInfo getWorkInfo() {
         return mWorkInfo;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfos.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfos.java
index 16e4e6b..d76055c 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfos.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkInfos.java
@@ -20,10 +20,11 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkInfo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -41,8 +42,7 @@
         mInfos = infos;
     }
 
-    @NonNull
-    public List<WorkInfo> getWorkInfos() {
+    public @NonNull List<WorkInfo> getWorkInfos() {
         return mInfos;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkQuery.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkQuery.java
index 42d1472..1b23ab1 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkQuery.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkQuery.java
@@ -23,11 +23,12 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkInfo;
 import androidx.work.WorkQuery;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -78,8 +79,7 @@
                 .build();
     }
 
-    @NonNull
-    public WorkQuery getWorkQuery() {
+    public @NonNull WorkQuery getWorkQuery() {
         return mWorkQuery;
     }
 
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequest.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequest.java
index 85c9a0c..b4d2128 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequest.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequest.java
@@ -29,12 +29,13 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkRequest;
 import androidx.work.impl.WorkRequestHolder;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -167,8 +168,7 @@
         parcel.writeString(workSpec.getTraceTag());
     }
 
-    @NonNull
-    public WorkRequest getWorkRequest() {
+    public @NonNull WorkRequest getWorkRequest() {
         return mWorkRequest;
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequests.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequests.java
index 639fb3f4..8239cb4 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequests.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkRequests.java
@@ -20,10 +20,11 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkRequest;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -77,8 +78,7 @@
         parcel.writeParcelableArray(parcelledArray, flags);
     }
 
-    @NonNull
-    public List<WorkRequest> getRequests() {
+    public @NonNull List<WorkRequest> getRequests() {
         return mRequests;
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkerParameters.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkerParameters.java
index 5aa72cc..7b2f14e 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkerParameters.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableWorkerParameters.java
@@ -20,7 +20,6 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Configuration;
 import androidx.work.Data;
@@ -34,6 +33,8 @@
 import androidx.work.impl.utils.WorkProgressUpdater;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -47,14 +48,10 @@
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 @SuppressLint("BanParcelableUsage")
 public class ParcelableWorkerParameters implements Parcelable {
-    @NonNull
-    private final UUID mId;
-    @NonNull
-    private final Data mData;
-    @NonNull
-    private final Set<String> mTags;
-    @NonNull
-    private final WorkerParameters.RuntimeExtras mRuntimeExtras;
+    private final @NonNull UUID mId;
+    private final @NonNull Data mData;
+    private final @NonNull Set<String> mTags;
+    private final WorkerParameters.@NonNull RuntimeExtras mRuntimeExtras;
     private final int mRunAttemptCount;
     private final int mGeneration;
 
@@ -70,8 +67,7 @@
     public static final Creator<ParcelableWorkerParameters> CREATOR =
             new Creator<ParcelableWorkerParameters>() {
                 @Override
-                @NonNull
-                public ParcelableWorkerParameters createFromParcel(Parcel in) {
+                public @NonNull ParcelableWorkerParameters createFromParcel(Parcel in) {
                     return new ParcelableWorkerParameters(in);
                 }
 
@@ -124,13 +120,11 @@
         parcel.writeInt(mGeneration);
     }
 
-    @NonNull
-    public UUID getId() {
+    public @NonNull UUID getId() {
         return mId;
     }
 
-    @NonNull
-    public Data getData() {
+    public @NonNull Data getData() {
         return mData;
     }
 
@@ -138,8 +132,7 @@
         return mRunAttemptCount;
     }
 
-    @NonNull
-    public Set<String> getTags() {
+    public @NonNull Set<String> getTags() {
         return mTags;
     }
 
@@ -147,8 +140,7 @@
      * Converts {@link ParcelableWorkerParameters} to an instance of {@link WorkerParameters}
      * lazily.
      */
-    @NonNull
-    public WorkerParameters toWorkerParameters(@NonNull WorkManagerImpl workManager) {
+    public @NonNull WorkerParameters toWorkerParameters(@NonNull WorkManagerImpl workManager) {
         Configuration configuration = workManager.getConfiguration();
         WorkDatabase workDatabase = workManager.getWorkDatabase();
         TaskExecutor taskExecutor = workManager.getWorkTaskExecutor();
@@ -171,8 +163,7 @@
      * Converts {@link ParcelableWorkerParameters} to an instance of {@link WorkerParameters}
      * lazily.
      */
-    @NonNull
-    public WorkerParameters toWorkerParameters(
+    public @NonNull WorkerParameters toWorkerParameters(
             @NonNull Configuration configuration,
             @NonNull TaskExecutor taskExecutor,
             @NonNull ProgressUpdater progressUpdater,
diff --git a/work/work-runtime-ktx/build.gradle b/work/work-runtime-ktx/build.gradle
index 29a313f..25699af 100644
--- a/work/work-runtime-ktx/build.gradle
+++ b/work/work-runtime-ktx/build.gradle
@@ -30,7 +30,7 @@
 }
 
 dependencies {
-    api project(":work:work-runtime")
+    api(project(":work:work-runtime"))
 }
 
 androidx {
diff --git a/work/work-runtime/build.gradle b/work/work-runtime/build.gradle
index 24bfba4..e27dbfc 100644
--- a/work/work-runtime/build.gradle
+++ b/work/work-runtime/build.gradle
@@ -63,6 +63,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     ksp("androidx.room:room-compiler:2.6.1")
     api("androidx.core:core:1.12.0")
     implementation("androidx.room:room-ktx:2.6.1")
@@ -108,6 +109,4 @@
     description = "Android WorkManager runtime library"
     failOnDeprecationWarnings = false
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/DefaultWorkerFactoryTest.java b/work/work-runtime/src/androidTest/java/androidx/work/DefaultWorkerFactoryTest.java
index cd18321..60c4d44 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/DefaultWorkerFactoryTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/DefaultWorkerFactoryTest.java
@@ -32,6 +32,8 @@
 import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor;
 import androidx.work.worker.TestWorker;
 
+import kotlinx.coroutines.Dispatchers;
+
 import org.hamcrest.CoreMatchers;
 import org.junit.Before;
 import org.junit.Test;
@@ -39,8 +41,6 @@
 
 import java.util.concurrent.Executor;
 
-import kotlinx.coroutines.Dispatchers;
-
 @RunWith(AndroidJUnit4.class)
 public class DefaultWorkerFactoryTest extends DatabaseTest {
 
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java b/work/work-runtime/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java
index a836a59..ae99368 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java
@@ -56,7 +56,6 @@
 import android.net.Uri;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.room.testing.MigrationTestHelper;
 import androidx.sqlite.db.SupportSQLiteDatabase;
 import androidx.test.core.app.ApplicationProvider;
@@ -83,6 +82,7 @@
 import androidx.work.worker.TestWorker;
 
 import org.hamcrest.Matchers;
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -682,8 +682,7 @@
     }
 
     // doesn't have COLUMN_RUN_IN_FOREGROUND
-    @NonNull
-    private ContentValues contentValuesPre8(String workSpecId) {
+    private @NonNull ContentValues contentValuesPre8(String workSpecId) {
         ContentValues contentValues = new ContentValues();
         contentValues.put("id", workSpecId);
         contentValues.put("state", WorkTypeConverters.StateIds.ENQUEUED);
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTest.java
index d3e769ed..11fc6b5b 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTest.java
@@ -32,7 +32,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.arch.core.executor.ArchTaskExecutor;
 import androidx.arch.core.executor.TaskExecutor;
 import androidx.test.core.app.ApplicationProvider;
@@ -49,6 +48,7 @@
 import androidx.work.impl.utils.taskexecutor.InstantWorkTaskExecutor;
 import androidx.work.worker.TestWorker;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -64,7 +64,6 @@
 import java.util.UUID;
 import java.util.concurrent.ExecutionException;
 
-
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public class WorkContinuationImplTest extends WorkManagerTest {
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java
index 345eba8..e84386d 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java
@@ -28,8 +28,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.arch.core.executor.ArchTaskExecutor;
 import androidx.lifecycle.Observer;
 import androidx.lifecycle.testing.TestLifecycleOwner;
@@ -48,6 +46,8 @@
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 import androidx.work.worker.RandomSleepTestWorker;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -199,7 +199,7 @@
         }
 
         @Override
-        public void schedule(@NonNull WorkSpec... workSpecs) {
+        public void schedule(WorkSpec @NonNull ... workSpecs) {
             synchronized (sLock) {
                 for (WorkSpec workSpec : workSpecs) {
                     assertThat(mScheduledWorkSpecIds.contains(workSpec.id), is(false));
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java
index ad31a45..0326a9de 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java
@@ -73,7 +73,6 @@
 import android.provider.MediaStore;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.arch.core.executor.ArchTaskExecutor;
 import androidx.arch.core.executor.TaskExecutor;
 import androidx.lifecycle.LiveData;
@@ -126,6 +125,7 @@
 
 import com.google.common.util.concurrent.Futures;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -2297,8 +2297,8 @@
                 new Dependency(work.getStringId(), prerequisiteWork.getStringId()));
     }
 
-    @NonNull
-    private static WorkInfo createWorkInfo(UUID id, WorkInfo.State state, List<String> tags) {
+    private static @NonNull WorkInfo createWorkInfo(UUID id, WorkInfo.State state,
+            List<String> tags) {
         return new WorkInfo(
                 id, state, new HashSet<>(tags), Data.EMPTY, Data.EMPTY, 0, 0,
                 Constraints.NONE, 0, null,
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java
index 10e4788..286067b 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java
@@ -49,8 +49,6 @@
 import android.net.Uri;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.core.util.Consumer;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -103,6 +101,8 @@
 
 import kotlinx.coroutines.Dispatchers;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -121,7 +121,6 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 
-
 @RunWith(AndroidJUnit4.class)
 public class WorkerWrapperTest extends DatabaseTest {
 
@@ -1004,8 +1003,7 @@
         assertBeginEndTraceSpans(periodicWork.getWorkSpec());
     }
 
-    @NonNull
-    private FutureListener runWorker(PeriodicWorkRequest periodicWork, Worker worker) {
+    private @NonNull FutureListener runWorker(PeriodicWorkRequest periodicWork, Worker worker) {
         WorkerWrapper workerWrapper =
                 createBuilder(periodicWork.getStringId()).withWorker(worker).build();
         FutureListener listener = createAndAddFutureListener(workerWrapper);
@@ -1282,9 +1280,8 @@
                         .setInputData(new Data.Builder().putString("foo", "bar").build()).build();
         insertWork(work);
         WorkerFactory factory = new WorkerFactory() {
-            @Nullable
             @Override
-            public ListenableWorker createWorker(@NonNull Context appContext,
+            public @Nullable ListenableWorker createWorker(@NonNull Context appContext,
                     @NonNull String workerClassName, @NonNull WorkerParameters workerParameters) {
                 throw new IllegalStateException("Thrown in WorkerFactory Exception");
             }
@@ -1400,8 +1397,8 @@
         );
     }
 
-    @Nullable
-    private LatchWorker getLatchWorker(WorkRequest work, ExecutorService executorService) {
+    private @Nullable LatchWorker getLatchWorker(WorkRequest work,
+            ExecutorService executorService) {
         return (LatchWorker) mConfiguration.getWorkerFactory().createWorkerWithDefaultFallback(
                 mContext.getApplicationContext(),
                 LatchWorker.class.getName(),
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/greedy/GreedySchedulerTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/greedy/GreedySchedulerTest.java
index f695ffa..8fa4e48 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/greedy/GreedySchedulerTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/greedy/GreedySchedulerTest.java
@@ -57,7 +57,6 @@
 
 import java.util.concurrent.TimeUnit;
 
-
 @RunWith(AndroidJUnit4.class)
 public class GreedySchedulerTest extends WorkManagerTest {
     private final Context mContext = ApplicationProvider.getApplicationContext();
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java
index 11dbfcd..f5d0472 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java
@@ -42,8 +42,6 @@
 import android.os.Looper;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
@@ -78,6 +76,8 @@
 import androidx.work.worker.TestWorker;
 
 import org.hamcrest.collection.IsIterableContainingInOrder;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -146,14 +146,12 @@
 
         TaskExecutor instantTaskExecutor = new TaskExecutor() {
             @Override
-            @NonNull
-            public Executor getMainThreadExecutor() {
+            public @NonNull Executor getMainThreadExecutor() {
                 return mMainThreadExecutor;
             }
 
-            @NonNull
             @Override
-            public SerialExecutor getSerialTaskExecutor() {
+            public @NonNull SerialExecutor getSerialTaskExecutor() {
                 return new SerialExecutorImpl(new SynchronousExecutor());
             }
         };
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java
index 040b3aa..50aef15 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java
@@ -22,13 +22,13 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
-import androidx.annotation.NonNull;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.work.impl.DefaultRunnableScheduler;
 import androidx.work.impl.model.WorkGenerationalId;
 import androidx.work.impl.utils.WorkTimer;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemjob/SystemJobServiceTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemjob/SystemJobServiceTest.java
index e1dbc7b..d671339 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemjob/SystemJobServiceTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/background/systemjob/SystemJobServiceTest.java
@@ -40,7 +40,6 @@
 import android.os.Build;
 import android.os.PersistableBundle;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.arch.core.executor.ArchTaskExecutor;
 import androidx.arch.core.executor.TaskExecutor;
@@ -66,6 +65,7 @@
 import androidx.work.worker.InfiniteTestWorker;
 import androidx.work.worker.NeverResolvedWorker;
 
+import org.jspecify.annotations.NonNull;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/LiveDataUtilsTest.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/LiveDataUtilsTest.java
index c186329..a8971d0 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/LiveDataUtilsTest.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/LiveDataUtilsTest.java
@@ -20,7 +20,6 @@
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 
-import androidx.annotation.Nullable;
 import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
 import androidx.arch.core.util.Function;
 import androidx.lifecycle.LiveData;
@@ -32,6 +31,7 @@
 import androidx.work.impl.utils.taskexecutor.InstantWorkTaskExecutor;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/taskexecutor/InstantWorkTaskExecutor.java b/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/taskexecutor/InstantWorkTaskExecutor.java
index 0e11978..d577b5a 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/taskexecutor/InstantWorkTaskExecutor.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/utils/taskexecutor/InstantWorkTaskExecutor.java
@@ -16,10 +16,11 @@
 
 package androidx.work.impl.utils.taskexecutor;
 
-import androidx.annotation.NonNull;
 import androidx.work.impl.utils.SerialExecutorImpl;
 import androidx.work.impl.utils.SynchronousExecutor;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -30,9 +31,8 @@
     private Executor mSynchronousExecutor = new SynchronousExecutor();
     private SerialExecutorImpl mBackgroundExecutor = new SerialExecutorImpl(mSynchronousExecutor);
 
-    @NonNull
     @Override
-    public Executor getMainThreadExecutor() {
+    public @NonNull Executor getMainThreadExecutor() {
         return mSynchronousExecutor;
     }
 
@@ -41,9 +41,8 @@
         runnable.run();
     }
 
-    @NonNull
     @Override
-    public SerialExecutor getSerialTaskExecutor() {
+    public @NonNull SerialExecutor getSerialTaskExecutor() {
         return mBackgroundExecutor;
     }
 }
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/ChainedArgumentWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/ChainedArgumentWorker.java
index 3d3d84b..ea3964b 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/ChainedArgumentWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/ChainedArgumentWorker.java
@@ -18,11 +18,12 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A worker that passes its inputs as outputs.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/EchoingWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/EchoingWorker.java
index b175651..c1d9d81 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/EchoingWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/EchoingWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 public class EchoingWorker extends Worker {
 
     public EchoingWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/ExceptionWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/ExceptionWorker.java
index 34271b0..5f289f5 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/ExceptionWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/ExceptionWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 public class ExceptionWorker extends Worker {
 
     public ExceptionWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/FailureWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/FailureWorker.java
index 393537b..0708733 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/FailureWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/FailureWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Worker that fails.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/InfiniteTestWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/InfiniteTestWorker.java
index d03b900..984a499 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/InfiniteTestWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/InfiniteTestWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Test Worker that loops until Thread is interrupted.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/InterruptionAwareWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/InterruptionAwareWorker.java
index b1a16c6..ff30160 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/InterruptionAwareWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/InterruptionAwareWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.CountDownLatch;
 
 public class InterruptionAwareWorker extends Worker {
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/LatchWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/LatchWorker.java
index 547a7f8..378e4b3 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/LatchWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/LatchWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.CountDownLatch;
 
 public class LatchWorker extends Worker {
@@ -34,9 +35,8 @@
         super(context, workerParams);
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         try {
             mEntrySignal.countDown();
             mLatch.await();
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/RandomSleepTestWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/RandomSleepTestWorker.java
index fee507f..9a7ff9d 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/RandomSleepTestWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/RandomSleepTestWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Random;
 
 /**
@@ -35,9 +36,8 @@
         super(context, workerParams);
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         int sleepDuration = new Random().nextInt(MAX_SLEEP_DURATION_MS);
         try {
             Log.d("RandomSleepTestWorker", "Sleeping : " + sleepDuration);
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/RetryWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/RetryWorker.java
index e2c95e2..4693f59 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/RetryWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/RetryWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Worker that requests retry continuously.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/ReturnNullResultWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/ReturnNullResultWorker.java
index 809bebf..23ebc5c 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/ReturnNullResultWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/ReturnNullResultWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A Worker that returns {@code null} for a {@link androidx.work.ListenableWorker.Result}.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/SleepTestWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/SleepTestWorker.java
index 3522e88..cae387c 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/SleepTestWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/SleepTestWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Worker that sleeps for 5 seconds before returning.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/StopAwareWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/StopAwareWorker.java
index 941fb0d..9fb4e62 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/StopAwareWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/StopAwareWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A Worker that loops until it has been stopped.
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/TestWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/TestWorker.java
index 084fa74..a3358dd 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/TestWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/TestWorker.java
@@ -19,10 +19,11 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Simple Test Worker
  */
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/worker/UsedWorker.java b/work/work-runtime/src/androidTest/java/androidx/work/worker/UsedWorker.java
index 9f2bd0f..cad90d7 100644
--- a/work/work-runtime/src/androidTest/java/androidx/work/worker/UsedWorker.java
+++ b/work/work-runtime/src/androidTest/java/androidx/work/worker/UsedWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A {@link Worker} that starts off in the {@code used} state.
  */
@@ -32,9 +33,8 @@
         setUsed();
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         return Result.success();
     }
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/ForegroundInfo.java b/work/work-runtime/src/main/java/androidx/work/ForegroundInfo.java
index b77c79e..76d95eb 100644
--- a/work/work-runtime/src/main/java/androidx/work/ForegroundInfo.java
+++ b/work/work-runtime/src/main/java/androidx/work/ForegroundInfo.java
@@ -18,7 +18,7 @@
 
 import android.app.Notification;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 /**
  * The information required when a {@link ListenableWorker} runs in the context of a foreground
@@ -84,8 +84,7 @@
     /**
      * @return The user visible {@link Notification}
      */
-    @NonNull
-    public Notification getNotification() {
+    public @NonNull Notification getNotification() {
         return mNotification;
     }
 
diff --git a/work/work-runtime/src/main/java/androidx/work/ForegroundUpdater.java b/work/work-runtime/src/main/java/androidx/work/ForegroundUpdater.java
index b721466..4bb2afd 100644
--- a/work/work-runtime/src/main/java/androidx/work/ForegroundUpdater.java
+++ b/work/work-runtime/src/main/java/androidx/work/ForegroundUpdater.java
@@ -18,10 +18,10 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -38,8 +38,7 @@
      * {@link ListenableWorker} transitions to running in the context of a foreground
      * {@link android.app.Service}.
      */
-    @NonNull
-    ListenableFuture<Void> setForegroundAsync(
+    @NonNull ListenableFuture<Void> setForegroundAsync(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull ForegroundInfo foregroundInfo);
diff --git a/work/work-runtime/src/main/java/androidx/work/ListenableWorker.java b/work/work-runtime/src/main/java/androidx/work/ListenableWorker.java
index 4264b43..3d5d37b 100644
--- a/work/work-runtime/src/main/java/androidx/work/ListenableWorker.java
+++ b/work/work-runtime/src/main/java/androidx/work/ListenableWorker.java
@@ -24,8 +24,6 @@
 
 import androidx.annotation.IntRange;
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
@@ -33,6 +31,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 import java.util.Set;
 import java.util.UUID;
@@ -200,8 +201,7 @@
      * @return A {@code com.google.common.util.concurrent.ListenableFuture} which resolves
      * after progress is persisted. Cancelling this future is a no-op.
      */
-    @NonNull
-    public ListenableFuture<Void> setProgressAsync(@NonNull Data data) {
+    public @NonNull ListenableFuture<Void> setProgressAsync(@NonNull Data data) {
         return mWorkerParams.getProgressUpdater()
                 .updateProgress(getApplicationContext(), getId(), data);
     }
@@ -229,8 +229,8 @@
      * the {@link ListenableWorker} transitions to running in the context of a foreground
      * {@link android.app.Service}.
      */
-    @NonNull
-    public final ListenableFuture<Void> setForegroundAsync(@NonNull ForegroundInfo foregroundInfo) {
+    public final @NonNull ListenableFuture<Void> setForegroundAsync(
+            @NonNull ForegroundInfo foregroundInfo) {
         return mWorkerParams.getForegroundUpdater()
                 .setForegroundAsync(getApplicationContext(), getId(), foregroundInfo);
     }
@@ -251,8 +251,7 @@
      * {@link ForegroundInfo} instance if the WorkRequest is marked immediate. For more
      * information look at {@link WorkRequest.Builder#setExpedited(OutOfQuotaPolicy)}.
      */
-    @NonNull
-    public ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
+    public @NonNull ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
         return CallbackToFutureAdapter.getFuture((completer) -> {
             String message =
                     "Expedited WorkRequests require a ListenableWorker to provide an implementation"
@@ -363,8 +362,7 @@
          *
          * @return An instance of {@link Result} indicating successful execution of work
          */
-        @NonNull
-        public static Result success() {
+        public static @NonNull Result success() {
             return new Success();
         }
 
@@ -377,8 +375,7 @@
          *                   OneTimeWorkRequest that is dependent on this work
          * @return An instance of {@link Result} indicating successful execution of work
          */
-        @NonNull
-        public static Result success(@NonNull Data outputData) {
+        public static @NonNull Result success(@NonNull Data outputData) {
             return new Success(outputData);
         }
 
@@ -389,8 +386,7 @@
          *
          * @return An instance of {@link Result} indicating that the work needs to be retried
          */
-        @NonNull
-        public static Result retry() {
+        public static @NonNull Result retry() {
             return new Retry();
         }
 
@@ -403,8 +399,7 @@
          *
          * @return An instance of {@link Result} indicating failure when executing work
          */
-        @NonNull
-        public static Result failure() {
+        public static @NonNull Result failure() {
             return new Failure();
         }
 
@@ -419,8 +414,7 @@
          *                   failed
          * @return An instance of {@link Result} indicating failure when executing work
          */
-        @NonNull
-        public static Result failure(@NonNull Data outputData) {
+        public static @NonNull Result failure(@NonNull Data outputData) {
             return new Failure(outputData);
         }
 
@@ -428,8 +422,7 @@
          * @return The output {@link Data} which will be merged into the input {@link Data} of
          * any {@link OneTimeWorkRequest} that is dependent on this work request.
          */
-        @NonNull
-        public abstract Data getOutputData();
+        public abstract @NonNull Data getOutputData();
 
         /**
          */
@@ -482,9 +475,8 @@
                 return 31 * name.hashCode() + mOutputData.hashCode();
             }
 
-            @NonNull
             @Override
-            public String toString() {
+            public @NonNull String toString() {
                 return "Success {" + "mOutputData=" + mOutputData + '}';
             }
         }
@@ -534,9 +526,8 @@
                 return 31 * name.hashCode() + mOutputData.hashCode();
             }
 
-            @NonNull
             @Override
-            public String toString() {
+            public @NonNull String toString() {
                 return "Failure {" +  "mOutputData=" + mOutputData +  '}';
             }
         }
@@ -566,16 +557,14 @@
                 return name.hashCode();
             }
 
-            @NonNull
             @Override
-            public Data getOutputData() {
+            public @NonNull Data getOutputData() {
                 return Data.EMPTY;
             }
 
 
-            @NonNull
             @Override
-            public String toString() {
+            public @NonNull String toString() {
                 return "Retry";
             }
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/Logger.java b/work/work-runtime/src/main/java/androidx/work/Logger.java
index 6c69513..2793e5d 100644
--- a/work/work-runtime/src/main/java/androidx/work/Logger.java
+++ b/work/work-runtime/src/main/java/androidx/work/Logger.java
@@ -18,9 +18,10 @@
 
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * The class that handles logging requests for {@link WorkManager}.  Currently, this class is not
  * accessible and has only one default implementation, {@link LogcatLogger}, that writes to logcat
@@ -55,8 +56,7 @@
      * @param tag The {@link String} tag to use when logging
      * @return The prefixed {@link String} tag to use when logging
      */
-    @NonNull
-    public static String tagWithPrefix(@NonNull String tag) {
+    public static @NonNull String tagWithPrefix(@NonNull String tag) {
         int length = tag.length();
         StringBuilder withPrefix = new StringBuilder(MAX_TAG_LENGTH);
         withPrefix.append(TAG_PREFIX);
@@ -72,8 +72,7 @@
     /**
      * @return The current {@link Logger}.
      */
-    @NonNull
-    public static Logger get() {
+    public static @NonNull Logger get() {
         // Logger may not be explicitly initialized by some tests which do not instantiate
         // WorkManagerImpl directly.
         //
diff --git a/work/work-runtime/src/main/java/androidx/work/Operation.java b/work/work-runtime/src/main/java/androidx/work/Operation.java
index 1eb01b7a..db11da5 100644
--- a/work/work-runtime/src/main/java/androidx/work/Operation.java
+++ b/work/work-runtime/src/main/java/androidx/work/Operation.java
@@ -16,7 +16,6 @@
 
 package androidx.work;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.LiveData;
@@ -24,6 +23,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * An object that provides information about the execution of an asynchronous command being
  * performed by {@link WorkManager}.  Operations are generally tied to enqueue or cancel commands;
@@ -51,8 +52,7 @@
      * @return A {@link LiveData} of the Operation {@link State}; you must use
      *         {@link LiveData#observe(LifecycleOwner, Observer)} to receive updates
      */
-    @NonNull
-    LiveData<State> getState();
+    @NonNull LiveData<State> getState();
 
     /**
      * Gets a {@link ListenableFuture} for the terminal state of the {@link Operation}.  This will
@@ -66,8 +66,7 @@
      * @return A {@link ListenableFuture} with information about {@link Operation}'s
      *         {@link State.SUCCESS} state.
      */
-    @NonNull
-    ListenableFuture<State.SUCCESS> getResult();
+    @NonNull ListenableFuture<State.SUCCESS> getResult();
 
     /**
      * The lifecycle state of an {@link Operation}.
@@ -91,8 +90,7 @@
             }
 
             @Override
-            @NonNull
-            public String toString() {
+            public @NonNull String toString() {
                 return "SUCCESS";
             }
         }
@@ -106,8 +104,7 @@
             }
 
             @Override
-            @NonNull
-            public String toString() {
+            public @NonNull String toString() {
                 return "IN_PROGRESS";
             }
         }
@@ -127,14 +124,12 @@
             /**
              * @return The {@link Throwable} which caused the {@link Operation} to fail.
              */
-            @NonNull
-            public Throwable getThrowable() {
+            public @NonNull Throwable getThrowable() {
                 return mThrowable;
             }
 
             @Override
-            @NonNull
-            public String toString() {
+            public @NonNull String toString() {
                 return "FAILURE (" + mThrowable.getMessage() + ")";
             }
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/ProgressUpdater.java b/work/work-runtime/src/main/java/androidx/work/ProgressUpdater.java
index f2c22d6..40a851e 100644
--- a/work/work-runtime/src/main/java/androidx/work/ProgressUpdater.java
+++ b/work/work-runtime/src/main/java/androidx/work/ProgressUpdater.java
@@ -18,10 +18,10 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -38,8 +38,7 @@
      * Cancelling this {@link ListenableFuture} does not cancel the writes to the database
      * to update progress.
      */
-    @NonNull
-    ListenableFuture<Void> updateProgress(
+    @NonNull ListenableFuture<Void> updateProgress(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull Data data);
diff --git a/work/work-runtime/src/main/java/androidx/work/RunnableScheduler.java b/work/work-runtime/src/main/java/androidx/work/RunnableScheduler.java
index d6b1379..a749449 100644
--- a/work/work-runtime/src/main/java/androidx/work/RunnableScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/RunnableScheduler.java
@@ -17,7 +17,8 @@
 package androidx.work;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
+
+import org.jspecify.annotations.NonNull;
 
 /**
  * Can be used to schedule {@link Runnable}s after a delay in milliseconds.
diff --git a/work/work-runtime/src/main/java/androidx/work/WorkContinuation.java b/work/work-runtime/src/main/java/androidx/work/WorkContinuation.java
index 94a939f..7413706 100644
--- a/work/work-runtime/src/main/java/androidx/work/WorkContinuation.java
+++ b/work/work-runtime/src/main/java/androidx/work/WorkContinuation.java
@@ -15,7 +15,6 @@
  */
 package androidx.work;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.LiveData;
@@ -23,6 +22,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Collections;
 import java.util.List;
 
diff --git a/work/work-runtime/src/main/java/androidx/work/WorkManagerInitializer.java b/work/work-runtime/src/main/java/androidx/work/WorkManagerInitializer.java
index 93acbdf..bc91f9a 100644
--- a/work/work-runtime/src/main/java/androidx/work/WorkManagerInitializer.java
+++ b/work/work-runtime/src/main/java/androidx/work/WorkManagerInitializer.java
@@ -18,9 +18,10 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.startup.Initializer;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Collections;
 import java.util.List;
 
@@ -31,18 +32,16 @@
 
     private static final String TAG = Logger.tagWithPrefix("WrkMgrInitializer");
 
-    @NonNull
     @Override
-    public WorkManager create(@NonNull Context context) {
+    public @NonNull WorkManager create(@NonNull Context context) {
         // Initialize WorkManager with the default configuration.
         Logger.get().debug(TAG, "Initializing WorkManager with default configuration.");
         WorkManager.initialize(context, new Configuration.Builder().build());
         return WorkManager.getInstance(context);
     }
 
-    @NonNull
     @Override
-    public List<Class<? extends androidx.startup.Initializer<?>>> dependencies() {
+    public @NonNull List<Class<? extends androidx.startup.Initializer<?>>> dependencies() {
         return Collections.emptyList();
     }
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/WorkerParameters.java b/work/work-runtime/src/main/java/androidx/work/WorkerParameters.java
index fa8f5a2..f38b6d1 100644
--- a/work/work-runtime/src/main/java/androidx/work/WorkerParameters.java
+++ b/work/work-runtime/src/main/java/androidx/work/WorkerParameters.java
@@ -20,14 +20,15 @@
 import android.net.Uri;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
 import kotlin.coroutines.CoroutineContext;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -238,7 +239,6 @@
         public @NonNull List<Uri> triggeredContentUris = Collections.emptyList();
 
         @RequiresApi(28)
-        @Nullable
-        public Network network;
+        public @Nullable Network network;
     }
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/DefaultRunnableScheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/DefaultRunnableScheduler.java
index 2583e96..82079c3 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/DefaultRunnableScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/DefaultRunnableScheduler.java
@@ -19,11 +19,12 @@
 import android.os.Handler;
 import android.os.Looper;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.core.os.HandlerCompat;
 import androidx.work.RunnableScheduler;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * The default implementation of the {@link androidx.work.RunnableScheduler} used by
  * {@link  androidx.work.impl.background.greedy.GreedyScheduler}.
@@ -38,8 +39,7 @@
         mHandler = HandlerCompat.createAsync(Looper.getMainLooper());
     }
 
-    @NonNull
-    public Handler getHandler() {
+    public @NonNull Handler getHandler() {
         return mHandler;
     }
 
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/ExecutionListener.java b/work/work-runtime/src/main/java/androidx/work/impl/ExecutionListener.java
index f3eb4235..a67f925 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/ExecutionListener.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/ExecutionListener.java
@@ -16,11 +16,12 @@
 
 package androidx.work.impl;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Worker;
 import androidx.work.impl.model.WorkGenerationalId;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Listener that reports the result of a {@link Worker}'s execution.
  *
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/Processor.java b/work/work-runtime/src/main/java/androidx/work/impl/Processor.java
index 95a2a95..ee69780 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/Processor.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/Processor.java
@@ -22,8 +22,6 @@
 import android.content.Intent;
 import android.os.PowerManager;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.ContextCompat;
 import androidx.work.Configuration;
@@ -38,6 +36,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -54,8 +55,7 @@
     private static final String TAG = Logger.tagWithPrefix("Processor");
     private static final String FOREGROUND_WAKELOCK_TAG = "ProcessorForegroundLck";
 
-    @Nullable
-    private PowerManager.WakeLock mForegroundLock;
+    private PowerManager.@Nullable WakeLock mForegroundLock;
 
     private Context mAppContext;
     private Configuration mConfiguration;
@@ -108,7 +108,7 @@
     @SuppressWarnings("ConstantConditions")
     public boolean startWork(
             @NonNull StartStopToken startStopToken,
-            @Nullable WorkerParameters.RuntimeExtras runtimeExtras) {
+            WorkerParameters.@Nullable RuntimeExtras runtimeExtras) {
         WorkGenerationalId id = startStopToken.getId();
         String workSpecId = id.getWorkSpecId();
         ArrayList<String> tags = new ArrayList<>();
@@ -352,8 +352,7 @@
         }
     }
 
-    @Nullable
-    private WorkerWrapper getWorkerWrapperUnsafe(@NonNull String workSpecId) {
+    private @Nullable WorkerWrapper getWorkerWrapperUnsafe(@NonNull String workSpecId) {
         WorkerWrapper workerWrapper = mForegroundWorkMap.get(workSpecId);
         if (workerWrapper == null) {
             workerWrapper = mEnqueuedWorkMap.get(workSpecId);
@@ -366,8 +365,7 @@
      *
      * @param workSpecId id of running worker
      */
-    @Nullable
-    public WorkSpec getRunningWorkSpec(@NonNull String workSpecId) {
+    public @Nullable WorkSpec getRunningWorkSpec(@NonNull String workSpecId) {
         synchronized (mLock) {
             WorkerWrapper workerWrapper = getWorkerWrapperUnsafe(workSpecId);
             if (workerWrapper != null) {
@@ -378,7 +376,7 @@
         }
     }
 
-    private void runOnExecuted(@NonNull final WorkGenerationalId id, boolean needsReschedule) {
+    private void runOnExecuted(final @NonNull WorkGenerationalId id, boolean needsReschedule) {
         mWorkTaskExecutor.getMainThreadExecutor().execute(
                 () -> {
                     synchronized (mLock) {
@@ -412,8 +410,7 @@
         }
     }
 
-    @Nullable
-    private WorkerWrapper cleanUpWorkerUnsafe(@NonNull String id) {
+    private @Nullable WorkerWrapper cleanUpWorkerUnsafe(@NonNull String id) {
         WorkerWrapper wrapper = mForegroundWorkMap.remove(id);
         boolean wasForeground = wrapper != null;
         if (!wasForeground) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/Scheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/Scheduler.java
index 399fbc0c..33b273f 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/Scheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/Scheduler.java
@@ -15,10 +15,11 @@
  */
 package androidx.work.impl;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * An interface for classes responsible for scheduling background work.
  *
@@ -43,7 +44,7 @@
      *
      * @param workSpecs The array of {@link WorkSpec}s to schedule
      */
-    void schedule(@NonNull WorkSpec... workSpecs);
+    void schedule(WorkSpec @NonNull ... workSpecs);
 
     /**
      * Cancel the work identified by the given {@link WorkSpec} id.
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/Schedulers.java b/work/work-runtime/src/main/java/androidx/work/impl/Schedulers.java
index b0786fe..1a24699 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/Schedulers.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/Schedulers.java
@@ -23,8 +23,6 @@
 import android.content.Context;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.work.Clock;
 import androidx.work.Configuration;
@@ -36,6 +34,9 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.model.WorkSpecDao;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 import java.util.concurrent.Executor;
 
@@ -147,8 +148,7 @@
         }
     }
 
-    @NonNull
-    static Scheduler createBestAvailableBackgroundScheduler(@NonNull Context context,
+    static @NonNull Scheduler createBestAvailableBackgroundScheduler(@NonNull Context context,
             @NonNull WorkDatabase workDatabase, Configuration configuration) {
 
         Scheduler scheduler;
@@ -168,8 +168,8 @@
         return scheduler;
     }
 
-    @Nullable
-    private static Scheduler tryCreateGcmBasedScheduler(@NonNull Context context, Clock clock) {
+    private static @Nullable Scheduler tryCreateGcmBasedScheduler(@NonNull Context context,
+            Clock clock) {
         try {
             Class<?> klass = Class.forName(GCM_SCHEDULER);
             Scheduler scheduler =
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/WorkContinuationImpl.java b/work/work-runtime/src/main/java/androidx/work/impl/WorkContinuationImpl.java
index 5a93ee6..22e7f4f 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/WorkContinuationImpl.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/WorkContinuationImpl.java
@@ -20,8 +20,6 @@
 
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.lifecycle.LiveData;
 import androidx.work.ArrayCreatingInputMerger;
@@ -40,6 +38,9 @@
 
 import kotlin.Unit;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
@@ -65,33 +66,27 @@
     private boolean mEnqueued;
     private Operation mOperation;
 
-    @NonNull
-    public WorkManagerImpl getWorkManagerImpl() {
+    public @NonNull WorkManagerImpl getWorkManagerImpl() {
         return mWorkManagerImpl;
     }
 
-    @Nullable
-    public String getName() {
+    public @Nullable String getName() {
         return mName;
     }
 
-    @NonNull
-    public ExistingWorkPolicy getExistingWorkPolicy() {
+    public @NonNull ExistingWorkPolicy getExistingWorkPolicy() {
         return mExistingWorkPolicy;
     }
 
-    @NonNull
-    public List<? extends WorkRequest> getWork() {
+    public @NonNull List<? extends WorkRequest> getWork() {
         return mWork;
     }
 
-    @NonNull
-    public List<String> getIds() {
+    public @NonNull List<String> getIds() {
         return mIds;
     }
 
-    @NonNull
-    public List<String> getAllIds() {
+    public @NonNull List<String> getAllIds() {
         return mAllIds;
     }
 
@@ -106,8 +101,7 @@
         mEnqueued = true;
     }
 
-    @Nullable
-    public List<WorkContinuationImpl> getParents() {
+    public @Nullable List<WorkContinuationImpl> getParents() {
         return mParents;
     }
 
@@ -180,9 +174,8 @@
         return mWorkManagerImpl.getWorkInfosById(mAllIds);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<List<WorkInfo>> getWorkInfos() {
+    public @NonNull ListenableFuture<List<WorkInfo>> getWorkInfos() {
         return StatusRunnable.forStringIds(mWorkManagerImpl.getWorkDatabase(),
                 mWorkManagerImpl.getWorkTaskExecutor(), mAllIds);
     }
@@ -281,9 +274,9 @@
     /**
      * @return the {@link Set} of pre-requisites for a given {@link WorkContinuationImpl}.
      */
-    @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public static Set<String> prerequisitesFor(@NonNull WorkContinuationImpl continuation) {
+    public static @NonNull Set<String> prerequisitesFor(
+            @NonNull WorkContinuationImpl continuation) {
         Set<String> preRequisites = new HashSet<>();
         List<WorkContinuationImpl> parents = continuation.getParents();
         if (parents != null && !parents.isEmpty()) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/WorkManagerImpl.java b/work/work-runtime/src/main/java/androidx/work/impl/WorkManagerImpl.java
index e8153ad..f78fba2 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/WorkManagerImpl.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/WorkManagerImpl.java
@@ -37,8 +37,6 @@
 import android.content.Intent;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.arch.core.util.Function;
@@ -82,6 +80,9 @@
 import kotlinx.coroutines.CoroutineScope;
 import kotlinx.coroutines.flow.Flow;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.UUID;
@@ -265,8 +266,7 @@
      * @return The application {@link Context} associated with this WorkManager.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @NonNull
-    public Context getApplicationContext() {
+    public @NonNull Context getApplicationContext() {
         return mContext;
     }
 
@@ -274,25 +274,22 @@
      * @return The {@link WorkDatabase} instance associated with this WorkManager.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @NonNull
-    public WorkDatabase getWorkDatabase() {
+    public @NonNull WorkDatabase getWorkDatabase() {
         return mWorkDatabase;
     }
 
     /**
      * @return workmanager's CoroutineScope
      */
-    @NonNull
-    CoroutineScope getWorkManagerScope() {
+    @NonNull CoroutineScope getWorkManagerScope() {
         return mWorkManagerScope;
     }
 
     /**
      * @return The {@link Configuration} instance associated with this WorkManager.
      */
-    @NonNull
     @Override
-    public Configuration getConfiguration() {
+    public @NonNull Configuration getConfiguration() {
         return mConfiguration;
     }
 
@@ -333,14 +330,12 @@
      * @return the {@link Trackers} used by {@link WorkManager}
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @NonNull
-    public Trackers getTrackers() {
+    public @NonNull Trackers getTrackers() {
         return mTrackers;
     }
 
     @Override
-    @NonNull
-    public Operation enqueue(
+    public @NonNull Operation enqueue(
             @NonNull List<? extends WorkRequest> requests) {
 
         // This error is not being propagated as part of the Operation, as we want the
@@ -373,9 +368,8 @@
         return new WorkContinuationImpl(this, uniqueWorkName, existingWorkPolicy, requests);
     }
 
-    @NonNull
     @Override
-    public Operation enqueueUniqueWork(@NonNull String uniqueWorkName,
+    public @NonNull Operation enqueueUniqueWork(@NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull List<OneTimeWorkRequest> requests) {
         return new WorkContinuationImpl(this, uniqueWorkName,
@@ -383,8 +377,7 @@
     }
 
     @Override
-    @NonNull
-    public Operation enqueueUniquePeriodicWork(
+    public @NonNull Operation enqueueUniquePeriodicWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingPeriodicWorkPolicy existingPeriodicWorkPolicy,
             @NonNull PeriodicWorkRequest request) {
@@ -401,8 +394,7 @@
     /**
      * Creates a {@link WorkContinuation} for the given unique {@link PeriodicWorkRequest}.
      */
-    @NonNull
-    public WorkContinuationImpl createWorkContinuationForUniquePeriodicWork(
+    public @NonNull WorkContinuationImpl createWorkContinuationForUniquePeriodicWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingPeriodicWorkPolicy existingPeriodicWorkPolicy,
             @NonNull PeriodicWorkRequest periodicWork) {
@@ -425,13 +417,12 @@
     }
 
     @Override
-    public @NonNull Operation cancelAllWorkByTag(@NonNull final String tag) {
+    public @NonNull Operation cancelAllWorkByTag(final @NonNull String tag) {
         return CancelWorkRunnable.forTag(tag, this);
     }
 
     @Override
-    @NonNull
-    public Operation cancelUniqueWork(@NonNull String uniqueWorkName) {
+    public @NonNull Operation cancelUniqueWork(@NonNull String uniqueWorkName) {
         return CancelWorkRunnable.forName(uniqueWorkName, this);
     }
 
@@ -440,9 +431,8 @@
         return CancelWorkRunnable.forAll(this);
     }
 
-    @NonNull
     @Override
-    public PendingIntent createCancelPendingIntent(@NonNull UUID id) {
+    public @NonNull PendingIntent createCancelPendingIntent(@NonNull UUID id) {
         Intent intent = createCancelWorkIntent(mContext, id.toString());
         int flags = FLAG_UPDATE_CURRENT;
         if (Build.VERSION.SDK_INT >= 31) {
@@ -487,9 +477,8 @@
                 mWorkTaskExecutor);
     }
 
-    @NonNull
     @Override
-    public Flow<WorkInfo> getWorkInfoByIdFlow(@NonNull UUID id) {
+    public @NonNull Flow<WorkInfo> getWorkInfoByIdFlow(@NonNull UUID id) {
         return getWorkStatusPojoFlowDataForIds(getWorkDatabase().workSpecDao(), id);
     }
 
@@ -498,9 +487,8 @@
         return StatusRunnable.forUUID(mWorkDatabase, mWorkTaskExecutor, id);
     }
 
-    @NonNull
     @Override
-    public Flow<List<WorkInfo>> getWorkInfosByTagFlow(@NonNull String tag) {
+    public @NonNull Flow<List<WorkInfo>> getWorkInfosByTagFlow(@NonNull String tag) {
         WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao();
         return getWorkStatusPojoFlowForTag(workSpecDao,
                 mWorkTaskExecutor.getTaskCoroutineDispatcher(), tag);
@@ -523,8 +511,7 @@
     }
 
     @Override
-    @NonNull
-    public LiveData<List<WorkInfo>> getWorkInfosForUniqueWorkLiveData(
+    public @NonNull LiveData<List<WorkInfo>> getWorkInfosForUniqueWorkLiveData(
             @NonNull String uniqueWorkName) {
         WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao();
         LiveData<List<WorkSpec.WorkInfoPojo>> inputLiveData =
@@ -535,24 +522,22 @@
                 mWorkTaskExecutor);
     }
 
-    @NonNull
     @Override
-    public Flow<List<WorkInfo>> getWorkInfosForUniqueWorkFlow(@NonNull String uniqueWorkName) {
+    public @NonNull Flow<List<WorkInfo>> getWorkInfosForUniqueWorkFlow(
+            @NonNull String uniqueWorkName) {
         WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao();
         return getWorkStatusPojoFlowForName(workSpecDao,
                 mWorkTaskExecutor.getTaskCoroutineDispatcher(), uniqueWorkName);
     }
 
     @Override
-    @NonNull
-    public ListenableFuture<List<WorkInfo>> getWorkInfosForUniqueWork(
+    public @NonNull ListenableFuture<List<WorkInfo>> getWorkInfosForUniqueWork(
             @NonNull String uniqueWorkName) {
         return StatusRunnable.forUniqueWork(mWorkDatabase, mWorkTaskExecutor, uniqueWorkName);
     }
 
-    @NonNull
     @Override
-    public LiveData<List<WorkInfo>> getWorkInfosLiveData(
+    public @NonNull LiveData<List<WorkInfo>> getWorkInfosLiveData(
             @NonNull WorkQuery workQuery) {
         RawWorkInfoDao rawWorkInfoDao = mWorkDatabase.rawWorkInfoDao();
         LiveData<List<WorkSpec.WorkInfoPojo>> inputLiveData =
@@ -564,23 +549,20 @@
                 mWorkTaskExecutor);
     }
 
-    @NonNull
     @Override
-    public Flow<List<WorkInfo>> getWorkInfosFlow(@NonNull WorkQuery workQuery) {
+    public @NonNull Flow<List<WorkInfo>> getWorkInfosFlow(@NonNull WorkQuery workQuery) {
         RawWorkInfoDao rawWorkInfoDao = mWorkDatabase.rawWorkInfoDao();
         return getWorkInfoPojosFlow(rawWorkInfoDao, mWorkTaskExecutor.getTaskCoroutineDispatcher(),
                 RawQueries.toRawQuery(workQuery));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<List<WorkInfo>> getWorkInfos(@NonNull WorkQuery workQuery) {
+    public @NonNull ListenableFuture<List<WorkInfo>> getWorkInfos(@NonNull WorkQuery workQuery) {
         return StatusRunnable.forWorkQuerySpec(mWorkDatabase, mWorkTaskExecutor, workQuery);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<UpdateResult> updateWork(@NonNull WorkRequest request) {
+    public @NonNull ListenableFuture<UpdateResult> updateWork(@NonNull WorkRequest request) {
         return WorkerUpdater.updateWorkImpl(this, request);
     }
 
@@ -597,9 +579,8 @@
     /**
      *
      */
-    @Nullable
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public RemoteWorkManager getRemoteWorkManager() {
+    public @Nullable RemoteWorkManager getRemoteWorkManager() {
         if (mRemoteWorkManager == null) {
             synchronized (sLock) {
                 if (mRemoteWorkManager == null) {
@@ -675,7 +656,7 @@
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public void setReschedulePendingResult(
-            @NonNull BroadcastReceiver.PendingResult rescheduleReceiverResult) {
+            BroadcastReceiver.@NonNull PendingResult rescheduleReceiverResult) {
         synchronized (sLock) {
             // if we have two broadcast in the row, finish old one and use new one
             if (mRescheduleReceiverResult != null) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/WorkRequestHolder.java b/work/work-runtime/src/main/java/androidx/work/impl/WorkRequestHolder.java
index b29ee1d4..c0a4026 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/WorkRequestHolder.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/WorkRequestHolder.java
@@ -16,11 +16,12 @@
 
 package androidx.work.impl;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.WorkRequest;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Set;
 import java.util.UUID;
 
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/DelayedWorkTracker.java b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/DelayedWorkTracker.java
index 83e5ad3..cbc52d5 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/DelayedWorkTracker.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/DelayedWorkTracker.java
@@ -16,7 +16,6 @@
 
 package androidx.work.impl.background.greedy;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Clock;
 import androidx.work.Logger;
@@ -24,10 +23,11 @@
 import androidx.work.impl.Scheduler;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.Map;
 
-
 /**
  * Keeps track of {@link androidx.work.WorkRequest}s that have a timing component in a
  * {@link GreedyScheduler}.
@@ -66,7 +66,7 @@
      * @param workSpec The {@link WorkSpec} corresponding to the {@link androidx.work.WorkRequest}
      * @param nextRunTime time when work should be executed
      */
-    public void schedule(@NonNull final WorkSpec workSpec, long nextRunTime) {
+    public void schedule(final @NonNull WorkSpec workSpec, long nextRunTime) {
         Runnable existing = mRunnables.remove(workSpec.id);
         if (existing != null) {
             mRunnableScheduler.cancel(existing);
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
index 715338b..f46d32b7 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
@@ -27,7 +27,6 @@
 import android.content.Context;
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Configuration;
@@ -50,13 +49,15 @@
 import androidx.work.impl.utils.ProcessUtils;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import kotlinx.coroutines.Job;
+
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
-import kotlinx.coroutines.Job;
-
 /**
  * A greedy {@link Scheduler} that schedules unconstrained, non-timed work.  It intentionally does
  * not acquire any WakeLocks, instead trying to brute-force them as time allows before the process
@@ -124,7 +125,7 @@
     }
 
     @Override
-    public void schedule(@NonNull WorkSpec... workSpecs) {
+    public void schedule(WorkSpec @NonNull ... workSpecs) {
         if (mInDefaultProcess == null) {
             checkDefaultProcess();
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/Alarms.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/Alarms.java
index 4f3c958..afa14b6 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/Alarms.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/Alarms.java
@@ -26,7 +26,6 @@
 import android.content.Intent;
 import android.os.Build;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
 import androidx.work.impl.WorkDatabase;
@@ -36,6 +35,8 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.utils.IdGenerator;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A common class for managing Alarms.
  *
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/CommandHandler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/CommandHandler.java
index 1e73418..1376030 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/CommandHandler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/CommandHandler.java
@@ -20,8 +20,6 @@
 import android.content.Intent;
 import android.os.Bundle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.WorkerThread;
 import androidx.work.Clock;
@@ -35,6 +33,9 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.model.WorkSpecDao;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -362,7 +363,7 @@
     }
 
     @SuppressWarnings("deprecation")
-    private static boolean hasKeys(@Nullable Bundle bundle, @NonNull String... keys) {
+    private static boolean hasKeys(@Nullable Bundle bundle, String @NonNull ... keys) {
         if (bundle == null || bundle.isEmpty()) {
             return false;
         } else {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintProxyUpdateReceiver.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintProxyUpdateReceiver.java
index cc15885..c181df6 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintProxyUpdateReceiver.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintProxyUpdateReceiver.java
@@ -21,8 +21,6 @@
 import android.content.Context;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.work.Logger;
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.background.systemalarm.ConstraintProxy.BatteryChargingProxy;
@@ -32,6 +30,8 @@
 import androidx.work.impl.utils.PackageManagerHelper;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
 /**
  * The {@link BroadcastReceiver} responsible for updating constraint proxies.
@@ -58,8 +58,7 @@
      * @return an {@link Intent} with information about the constraint proxies which need to be
      * enabled.
      */
-    @NonNull
-    public static Intent newConstraintProxyUpdateIntent(
+    public static @NonNull Intent newConstraintProxyUpdateIntent(
             @NonNull Context context,
             boolean batteryNotLowProxyEnabled,
             boolean batteryChargingProxyEnabled,
@@ -80,7 +79,7 @@
     }
 
     @Override
-    public void onReceive(@NonNull final Context context, @Nullable final Intent intent) {
+    public void onReceive(final @NonNull Context context, final @Nullable Intent intent) {
         String action = intent != null ? intent.getAction() : null;
         if (!ACTION.equals(action)) {
             Logger.get().debug(TAG, "Ignoring unknown action " + action);
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintsCommandHandler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintsCommandHandler.java
index e59758c..79e1821 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintsCommandHandler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/ConstraintsCommandHandler.java
@@ -21,7 +21,6 @@
 import android.content.Context;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.WorkerThread;
 import androidx.work.Clock;
@@ -30,6 +29,8 @@
 import androidx.work.impl.constraints.trackers.Trackers;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/DelayMetCommandHandler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/DelayMetCommandHandler.java
index 65151de..40be2e4 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/DelayMetCommandHandler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/DelayMetCommandHandler.java
@@ -23,8 +23,6 @@
 import android.content.Intent;
 import android.os.PowerManager;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.WorkerThread;
 import androidx.work.Logger;
@@ -38,11 +36,14 @@
 import androidx.work.impl.utils.WakeLocks;
 import androidx.work.impl.utils.WorkTimer;
 
-import java.util.concurrent.Executor;
-
 import kotlinx.coroutines.CoroutineDispatcher;
 import kotlinx.coroutines.Job;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.util.concurrent.Executor;
+
 /**
  * This is a command handler which attempts to run a work spec given its id.
  * Also handles constraints gracefully.
@@ -96,7 +97,7 @@
     private final Executor mSerialExecutor;
     private final Executor mMainThreadExecutor;
 
-    @Nullable private PowerManager.WakeLock mWakeLock;
+    private PowerManager.@Nullable WakeLock mWakeLock;
     private boolean mHasConstraints;
     private final StartStopToken mToken;
     private final CoroutineDispatcher mCoroutineDispatcher;
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcher.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcher.java
index 43dfe44..48df964 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcher.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcher.java
@@ -23,8 +23,6 @@
 import android.text.TextUtils;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Logger;
@@ -40,6 +38,9 @@
 import androidx.work.impl.utils.taskexecutor.SerialExecutor;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -71,8 +72,7 @@
     final List<Intent> mIntents;
     Intent mCurrentIntent;
 
-    @Nullable
-    private CommandsCompletedListener mCompletedListener;
+    private @Nullable CommandsCompletedListener mCompletedListener;
 
     private StartStopTokens mStartStopTokens;
     private final WorkLauncher mWorkLauncher;
@@ -140,7 +140,7 @@
      * @return <code>true</code> when the command was added to the command processor queue.
      */
     @MainThread
-    public boolean add(@NonNull final Intent intent, final int startId) {
+    public boolean add(final @NonNull Intent intent, final int startId) {
         Logger.get().debug(TAG, "Adding command " + intent + " (" + startId + ")");
         assertMainThread();
         String action = intent.getAction();
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmScheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmScheduler.java
index 39f9497..b61c285 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemalarm/SystemAlarmScheduler.java
@@ -21,12 +21,13 @@
 import android.content.Context;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
 import androidx.work.impl.Scheduler;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A {@link Scheduler} that schedules work using {@link android.app.AlarmManager}.
  *
@@ -43,7 +44,7 @@
     }
 
     @Override
-    public void schedule(@NonNull WorkSpec... workSpecs) {
+    public void schedule(WorkSpec @NonNull ... workSpecs) {
         for (WorkSpec workSpec : workSpecs) {
             scheduleWorkSpec(workSpec);
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobInfoConverter.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobInfoConverter.java
index 158f9fc..b90d3ac 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobInfoConverter.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobInfoConverter.java
@@ -18,7 +18,6 @@
 
 import static androidx.work.impl.background.systemjob.SystemJobInfoConverterExtKt.setRequiredNetworkRequest;
 
-import android.annotation.SuppressLint;
 import android.app.job.JobInfo;
 import android.content.ComponentName;
 import android.content.Context;
@@ -27,7 +26,6 @@
 import android.os.Build;
 import android.os.PersistableBundle;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.work.BackoffPolicy;
@@ -38,13 +36,14 @@
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Converts a {@link WorkSpec} into a JobInfo.
  *
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 @RequiresApi(api = WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL)
-@SuppressLint("ClassVerificationFailure")
 class SystemJobInfoConverter {
     private static final String TAG = Logger.tagWithPrefix("SystemJobInfoConverter");
 
@@ -164,7 +163,7 @@
      * @param networkType The {@link NetworkType} instance.
      */
     static void setRequiredNetwork(
-            @NonNull JobInfo.Builder builder,
+            JobInfo.@NonNull Builder builder,
             @NonNull NetworkType networkType) {
 
         if (Build.VERSION.SDK_INT >= 30 && networkType == NetworkType.TEMPORARILY_UNMETERED) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobScheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobScheduler.java
index f3301ee..a184d32 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobScheduler.java
@@ -35,8 +35,6 @@
 import android.os.Build;
 import android.os.PersistableBundle;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
@@ -52,6 +50,9 @@
 import androidx.work.impl.model.WorkSpecDao;
 import androidx.work.impl.utils.IdGenerator;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -100,7 +101,7 @@
     }
 
     @Override
-    public void schedule(@NonNull WorkSpec... workSpecs) {
+    public void schedule(WorkSpec @NonNull ... workSpecs) {
         IdGenerator idGenerator = new IdGenerator(mWorkDatabase);
 
         for (WorkSpec workSpec : workSpecs) {
@@ -357,8 +358,7 @@
         return needsReconciling;
     }
 
-    @Nullable
-    static List<JobInfo> getPendingJobs(
+    static @Nullable List<JobInfo> getPendingJobs(
             @NonNull Context context,
             @NonNull JobScheduler jobScheduler) {
         List<JobInfo> pendingJobs = getSafePendingJobs(jobScheduler);
@@ -383,8 +383,7 @@
      *
      * For reference: b/133556574, b/133556809, b/133556535
      */
-    @Nullable
-    private static List<Integer> getPendingJobIds(
+    private static @Nullable List<Integer> getPendingJobIds(
             @NonNull Context context,
             @NonNull JobScheduler jobScheduler,
             @NonNull String workSpecId) {
@@ -407,8 +406,8 @@
         return jobIds;
     }
 
-    @Nullable
-    private static WorkGenerationalId getWorkGenerationalIdFromJobInfo(@NonNull JobInfo jobInfo) {
+    private static @Nullable WorkGenerationalId getWorkGenerationalIdFromJobInfo(
+            @NonNull JobInfo jobInfo) {
         PersistableBundle extras = jobInfo.getExtras();
         try {
             if (extras != null && extras.containsKey(EXTRA_WORK_SPEC_ID)) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobService.java b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobService.java
index 009b245..283ab2f 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobService.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/systemjob/SystemJobService.java
@@ -47,8 +47,6 @@
 import android.os.PersistableBundle;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
@@ -63,6 +61,9 @@
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.model.WorkGenerationalId;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
@@ -220,9 +221,8 @@
         }
     }
 
-    @Nullable
     @SuppressWarnings("ConstantConditions")
-    private static WorkGenerationalId workGenerationalIdFromJobParameters(
+    private static @Nullable WorkGenerationalId workGenerationalIdFromJobParameters(
             @NonNull JobParameters parameters
     ) {
         try {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/diagnostics/DiagnosticsReceiver.java b/work/work-runtime/src/main/java/androidx/work/impl/diagnostics/DiagnosticsReceiver.java
index b3f54e8..292f3a4 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/diagnostics/DiagnosticsReceiver.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/diagnostics/DiagnosticsReceiver.java
@@ -20,14 +20,15 @@
 import android.content.Context;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.work.Logger;
 import androidx.work.OneTimeWorkRequest;
 import androidx.work.WorkManager;
 import androidx.work.impl.workers.DiagnosticsWorker;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * The {@link android.content.BroadcastReceiver} which dumps out useful diagnostics information.
  *
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/foreground/ForegroundProcessor.java b/work/work-runtime/src/main/java/androidx/work/impl/foreground/ForegroundProcessor.java
index 2958019..4c514f3 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/foreground/ForegroundProcessor.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/foreground/ForegroundProcessor.java
@@ -16,10 +16,11 @@
 
 package androidx.work.impl.foreground;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.ForegroundInfo;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * An interface that provides {@link androidx.work.impl.WorkerWrapper} the hooks to move a
  * {@link androidx.work.ListenableWorker}s execution to the foreground.
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundDispatcher.java b/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundDispatcher.java
index a327b14..19dedd2 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundDispatcher.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundDispatcher.java
@@ -28,8 +28,6 @@
 import android.text.TextUtils;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.ForegroundInfo;
@@ -45,14 +43,17 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import kotlinx.coroutines.Job;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.UUID;
 
-import kotlinx.coroutines.Job;
-
 /**
  * Handles requests for executing {@link androidx.work.WorkRequest}s on behalf of
  * {@link SystemForegroundService}.
@@ -100,8 +101,7 @@
     @SuppressWarnings("WeakerAccess") // Synthetic access
     final WorkConstraintsTracker mConstraintsTracker;
 
-    @Nullable
-    private Callback mCallback;
+    private @Nullable Callback mCallback;
 
     SystemForegroundDispatcher(@NonNull Context context) {
         mContext = context;
@@ -370,8 +370,7 @@
      *                   foreground service
      * @return The {@link Intent}
      */
-    @NonNull
-    public static Intent createStartForegroundIntent(
+    public static @NonNull Intent createStartForegroundIntent(
             @NonNull Context context,
             @NonNull WorkGenerationalId id,
             @NonNull ForegroundInfo info) {
@@ -393,8 +392,7 @@
      *                   foreground service
      * @return The {@link Intent}
      */
-    @NonNull
-    public static Intent createCancelWorkIntent(
+    public static @NonNull Intent createCancelWorkIntent(
             @NonNull Context context,
             @NonNull String workSpecId) {
         Intent intent = new Intent(context, SystemForegroundService.class);
@@ -414,8 +412,7 @@
      * @param info       The {@link ForegroundInfo}
      * @return The {@link Intent}
      */
-    @NonNull
-    public static Intent createNotifyIntent(
+    public static @NonNull Intent createNotifyIntent(
             @NonNull Context context,
             @NonNull WorkGenerationalId id,
             @NonNull ForegroundInfo info) {
@@ -435,8 +432,7 @@
      * @param context The application {@link Context}
      * @return The {@link Intent}
      */
-    @NonNull
-    public static Intent createStopForegroundIntent(@NonNull Context context) {
+    public static @NonNull Intent createStopForegroundIntent(@NonNull Context context) {
         Intent intent = new Intent(context, SystemForegroundService.class);
         intent.setAction(ACTION_STOP_FOREGROUND);
         return intent;
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundService.java b/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundService.java
index b48701daf..819d686 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundService.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/foreground/SystemForegroundService.java
@@ -28,14 +28,15 @@
 import android.os.Build;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RequiresPermission;
 import androidx.annotation.RestrictTo;
 import androidx.lifecycle.LifecycleService;
 import androidx.work.Logger;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -44,8 +45,7 @@
 
     private static final String TAG = Logger.tagWithPrefix("SystemFgService");
 
-    @Nullable
-    private static SystemForegroundService sForegroundService = null;
+    private static @Nullable SystemForegroundService sForegroundService = null;
 
     private boolean mIsShutdown;
 
@@ -133,7 +133,7 @@
     public void startForeground(
             final int notificationId,
             final int notificationType,
-            @NonNull final Notification notification) {
+            final @NonNull Notification notification) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
             Api31Impl.startForeground(SystemForegroundService.this, notificationId,
                     notification, notificationType);
@@ -148,7 +148,7 @@
     @MainThread
     @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
     @Override
-    public void notify(final int notificationId, @NonNull final Notification notification) {
+    public void notify(final int notificationId, final @NonNull Notification notification) {
         mNotificationManager.notify(notificationId, notification);
     }
 
@@ -161,8 +161,7 @@
     /**
      * @return The current instance of {@link SystemForegroundService}.
      */
-    @Nullable
-    public static SystemForegroundService getInstance() {
+    public static @Nullable SystemForegroundService getInstance() {
         return sForegroundService;
     }
 
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/EnqueueRunnable.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/EnqueueRunnable.java
index 52eb1d3..b0a9d4d 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/EnqueueRunnable.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/EnqueueRunnable.java
@@ -30,7 +30,6 @@
 
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.ExistingWorkPolicy;
@@ -47,6 +46,8 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.model.WorkSpecDao;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
index 2e7707d..e347899 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
@@ -25,7 +25,6 @@
 import static androidx.work.WorkInfo.State.ENQUEUED;
 import static androidx.work.impl.model.WorkSpec.SCHEDULE_NOT_REQUESTED_YET;
 
-import android.annotation.SuppressLint;
 import android.app.ActivityManager;
 import android.app.AlarmManager;
 import android.app.ApplicationExitInfo;
@@ -45,8 +44,6 @@
 import android.os.Build;
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.core.os.UserManagerCompat;
@@ -63,6 +60,9 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.model.WorkSpecDao;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
@@ -193,7 +193,6 @@
      * @return {@code true} If the application was force stopped.
      */
     @VisibleForTesting
-    @SuppressLint("ClassVerificationFailure")
     public boolean isForceStopped() {
         // Alarms get cancelled when an app is force-stopped starting at Eclair MR1.
         // Cancelling of Jobs on force-stop was introduced in N-MR1 (SDK 25).
@@ -385,7 +384,6 @@
         return intent;
     }
 
-    @SuppressLint("ClassVerificationFailure")
     static void setAlarm(Context context) {
         AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
         // Using FLAG_UPDATE_CURRENT, because we only ever want once instance of this alarm.
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/LiveDataUtils.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/LiveDataUtils.java
index 225607e..6821a84 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/LiveDataUtils.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/LiveDataUtils.java
@@ -19,8 +19,6 @@
 
 import android.annotation.SuppressLint;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.arch.core.util.Function;
 import androidx.lifecycle.LiveData;
@@ -28,6 +26,9 @@
 import androidx.lifecycle.Observer;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 /**
  * Utility methods for {@link LiveData}.
  *
@@ -50,11 +51,10 @@
      * @return A new {@link LiveData} of type {@code Out}
      */
     @SuppressLint("LambdaLast")
-    @NonNull
-    public static <In, Out> LiveData<Out> dedupedMappedLiveDataFor(
+    public static <In, Out> @NonNull LiveData<Out> dedupedMappedLiveDataFor(
             @NonNull LiveData<In> inputLiveData,
-            @NonNull final Function<In, Out> mappingMethod,
-            @NonNull final TaskExecutor workTaskExecutor) {
+            final @NonNull Function<In, Out> mappingMethod,
+            final @NonNull TaskExecutor workTaskExecutor) {
 
         final Object lock = new Object();
         final MediatorLiveData<Out> outputLiveData = new MediatorLiveData<>();
@@ -64,7 +64,7 @@
             Out mCurrentOutput = null;
 
             @Override
-            public void onChanged(@Nullable final In input) {
+            public void onChanged(final @Nullable In input) {
                 workTaskExecutor.executeOnTaskThread(new Runnable() {
                     @Override
                     public void run() {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/PackageManagerHelper.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/PackageManagerHelper.java
index 3708813..b9ab5a2 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/PackageManagerHelper.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/PackageManagerHelper.java
@@ -19,9 +19,10 @@
 import android.content.Context;
 import android.content.pm.PackageManager;
 
-import androidx.annotation.NonNull;
 import androidx.work.Logger;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Helper class for common {@link PackageManager} functions
  */
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/PreferenceUtils.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/PreferenceUtils.java
index a981bc4..08d8ff0 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/PreferenceUtils.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/PreferenceUtils.java
@@ -18,11 +18,9 @@
 
 import static android.content.Context.MODE_PRIVATE;
 
-
 import android.content.Context;
 import android.content.SharedPreferences;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.Transformations;
@@ -30,6 +28,8 @@
 import androidx.work.impl.WorkDatabase;
 import androidx.work.impl.model.Preference;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * Preference Utils for WorkManager.
  *
@@ -71,8 +71,7 @@
      * @return A {@link LiveData} of the last time (in milliseconds) a {@code cancelAll} method was
      *         called
      */
-    @NonNull
-    public LiveData<Long> getLastCancelAllTimeMillisLiveData() {
+    public @NonNull LiveData<Long> getLastCancelAllTimeMillisLiveData() {
         LiveData<Long> observableValue =
                 mWorkDatabase.preferenceDao().getObservableLongValue(KEY_LAST_CANCEL_ALL_TIME_MS);
         return Transformations.map(observableValue, (Long value) -> value != null ? value : 0L);
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/SerialExecutorImpl.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/SerialExecutorImpl.java
index 4339438..1bd475e 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/SerialExecutorImpl.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/SerialExecutorImpl.java
@@ -17,10 +17,11 @@
 package androidx.work.impl.utils;
 
 import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.impl.utils.taskexecutor.SerialExecutor;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayDeque;
 import java.util.concurrent.Executor;
 
@@ -71,9 +72,8 @@
         }
     }
 
-    @NonNull
     @VisibleForTesting
-    public Executor getDelegatedExecutor() {
+    public @NonNull Executor getDelegatedExecutor() {
         return mExecutor;
     }
 
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/SynchronousExecutor.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/SynchronousExecutor.java
index 95edf78..39b1637 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/SynchronousExecutor.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/SynchronousExecutor.java
@@ -16,9 +16,10 @@
 
 package androidx.work.impl.utils;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkForegroundUpdater.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkForegroundUpdater.java
index 8639934..314127b 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkForegroundUpdater.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkForegroundUpdater.java
@@ -16,15 +16,14 @@
 
 package androidx.work.impl.utils;
 
+import static androidx.work.ListenableFutureKt.executeAsync;
 import static androidx.work.impl.foreground.SystemForegroundDispatcher.createNotifyIntent;
 import static androidx.work.impl.model.WorkSpecKt.generationalId;
-import static androidx.work.ListenableFutureKt.executeAsync;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Intent;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.ForegroundInfo;
 import androidx.work.ForegroundUpdater;
@@ -37,8 +36,9 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
-import java.util.UUID;
+import org.jspecify.annotations.NonNull;
 
+import java.util.UUID;
 
 /**
  * Transitions a {@link androidx.work.ListenableWorker} to run in the context of a foreground
@@ -70,12 +70,11 @@
         mWorkSpecDao = workDatabase.workSpecDao();
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> setForegroundAsync(
-            @NonNull final Context context,
-            @NonNull final UUID id,
-            @NonNull final ForegroundInfo foregroundInfo) {
+    public @NonNull ListenableFuture<Void> setForegroundAsync(
+            final @NonNull Context context,
+            final @NonNull UUID id,
+            final @NonNull ForegroundInfo foregroundInfo) {
         return executeAsync(mTaskExecutor.getSerialTaskExecutor(), "setForegroundAsync",
                 () -> {
                     String workSpecId = id.toString();
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkProgressUpdater.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkProgressUpdater.java
index f1d12ba5..21ef153 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkProgressUpdater.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkProgressUpdater.java
@@ -20,7 +20,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.Logger;
@@ -34,6 +33,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -61,12 +62,11 @@
         mTaskExecutor = taskExecutor;
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> updateProgress(
-            @NonNull final Context context,
-            @NonNull final UUID id,
-            @NonNull final Data data) {
+    public @NonNull ListenableFuture<Void> updateProgress(
+            final @NonNull Context context,
+            final @NonNull UUID id,
+            final @NonNull Data data) {
         return executeAsync(mTaskExecutor.getSerialTaskExecutor(), "updateProgress", () -> {
             String workSpecId = id.toString();
             Logger.get().debug(TAG, "Updating progress for " + id + " (" + data + ")");
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkTimer.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkTimer.java
index bbd9da2..e61f19a 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkTimer.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/WorkTimer.java
@@ -16,7 +16,6 @@
 
 package androidx.work.impl.utils;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Logger;
@@ -24,6 +23,8 @@
 import androidx.work.WorkRequest;
 import androidx.work.impl.model.WorkGenerationalId;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.HashMap;
 import java.util.Map;
 
@@ -61,7 +62,7 @@
      *                             {@code processingTimeMillis}
      */
     @SuppressWarnings("FutureReturnValueIgnored")
-    public void startTimer(@NonNull final WorkGenerationalId id,
+    public void startTimer(final @NonNull WorkGenerationalId id,
             long processingTimeMillis,
             @NonNull TimeLimitExceededListener listener) {
 
@@ -81,7 +82,7 @@
      *
      * @param id The {@link androidx.work.impl.model.WorkSpec} id
      */
-    public void stopTimer(@NonNull final WorkGenerationalId id) {
+    public void stopTimer(final @NonNull WorkGenerationalId id) {
         synchronized (mLock) {
             WorkTimerRunnable removed = mTimerMap.remove(id);
             if (removed != null) {
@@ -92,16 +93,14 @@
     }
 
     @VisibleForTesting
-    @NonNull
-    public Map<WorkGenerationalId, WorkTimerRunnable> getTimerMap() {
+    public @NonNull Map<WorkGenerationalId, WorkTimerRunnable> getTimerMap() {
         synchronized (mLock) {
             return mTimerMap;
         }
     }
 
     @VisibleForTesting
-    @NonNull
-    public Map<WorkGenerationalId, TimeLimitExceededListener> getListeners() {
+    public @NonNull Map<WorkGenerationalId, TimeLimitExceededListener> getListeners() {
         synchronized (mLock) {
             return mListeners;
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/AbstractFuture.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/AbstractFuture.java
index 5a8e7d5..49e1763 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/AbstractFuture.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/AbstractFuture.java
@@ -18,12 +18,13 @@
 
 import static java.util.concurrent.atomic.AtomicReferenceFieldUpdater.newUpdater;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.Locale;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
@@ -122,10 +123,8 @@
     private static final class Waiter {
         static final Waiter TOMBSTONE = new Waiter(false /* ignored param */);
 
-        @Nullable
-        volatile Thread thread;
-        @Nullable
-        volatile Waiter next;
+        volatile @Nullable Thread thread;
+        volatile @Nullable Waiter next;
 
         /**
          * Constructor for the TOMBSTONE, avoids use of ATOMIC_HELPER in case this class is loaded
@@ -205,8 +204,7 @@
         final Executor executor;
 
         // writes to next are made visible by subsequent CAS's on the listeners field
-        @Nullable
-        Listener next;
+        @Nullable Listener next;
 
         Listener(Runnable task, Executor executor) {
             this.task = task;
@@ -251,8 +249,7 @@
         }
 
         final boolean wasInterrupted;
-        @Nullable
-        final Throwable cause;
+        final @Nullable Throwable cause;
 
         Cancellation(boolean wasInterrupted, @Nullable Throwable cause) {
             this.wasInterrupted = wasInterrupted;
@@ -300,19 +297,16 @@
      * argument.
      * </ul>
      */
-    @Nullable
     @SuppressWarnings("WeakerAccess") // Avoiding synthetic accessor.
-    volatile Object value;
+    volatile @Nullable Object value;
 
     /** All listeners. */
-    @Nullable
     @SuppressWarnings("WeakerAccess") // Avoiding synthetic accessor.
-    volatile Listener listeners;
+    volatile @Nullable Listener listeners;
 
     /** All waiting threads. */
-    @Nullable
     @SuppressWarnings("WeakerAccess") // Avoiding synthetic accessor.
-    volatile Waiter waiters;
+    volatile @Nullable Waiter waiters;
 
     /** Constructor for use by subclasses. */
     protected AbstractFuture() {
@@ -1002,8 +996,7 @@
      * @return null if an explanation cannot be provided because the future is done.
      * @since 23.0
      */
-    @Nullable
-    protected String pendingToString() {
+    protected @Nullable String pendingToString() {
         Object localValue = value;
         if (localValue instanceof SetFuture) {
             return "setFuture=[" + userObjectToString(((SetFuture) localValue).future) + "]";
@@ -1186,8 +1179,7 @@
     }
 
     @SuppressWarnings("WeakerAccess") // Avoiding synthetic accessor.
-    @NonNull
-    static <T> T checkNotNull(@Nullable T reference) {
+    static <T> @NonNull T checkNotNull(@Nullable T reference) {
         if (reference == null) {
             throw new NullPointerException();
         }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/SettableFuture.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/SettableFuture.java
index 2043ef1..ef94345 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/SettableFuture.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/futures/SettableFuture.java
@@ -16,11 +16,12 @@
 
 package androidx.work.impl.utils.futures;
 
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Cloned from concurrent-futures package to avoid AndroidX namespace issues since there is no
  * supportlib 28.* equivalent of this class.
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/TaskExecutor.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/TaskExecutor.java
index e61932a..60a71dc 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/TaskExecutor.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/TaskExecutor.java
@@ -16,15 +16,16 @@
 
 package androidx.work.impl.utils.taskexecutor;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Configuration;
 
-import java.util.concurrent.Executor;
-
 import kotlinx.coroutines.CoroutineDispatcher;
 import kotlinx.coroutines.ExecutorsKt;
 
+import org.jspecify.annotations.NonNull;
+
+import java.util.concurrent.Executor;
+
 /**
  * Interface for executing common tasks in WorkManager.
  *
@@ -35,8 +36,7 @@
     /**
      * @return The {@link Executor} for main thread task processing
      */
-    @NonNull
-    Executor getMainThreadExecutor();
+    @NonNull Executor getMainThreadExecutor();
 
     /**
      * @param runnable {@link Runnable} to execute on a thread pool used
@@ -53,11 +53,9 @@
      *
      * @return The {@link Executor} for internal book-keeping
      */
-    @NonNull
-    SerialExecutor getSerialTaskExecutor();
+    @NonNull SerialExecutor getSerialTaskExecutor();
 
-    @NonNull
-    default CoroutineDispatcher getTaskCoroutineDispatcher() {
+    default @NonNull CoroutineDispatcher getTaskCoroutineDispatcher() {
         return ExecutorsKt.from(getSerialTaskExecutor());
     }
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/WorkManagerTaskExecutor.java b/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/WorkManagerTaskExecutor.java
index 9c4f92c..1866e90 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/WorkManagerTaskExecutor.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/utils/taskexecutor/WorkManagerTaskExecutor.java
@@ -19,15 +19,16 @@
 import android.os.Handler;
 import android.os.Looper;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.impl.utils.SerialExecutorImpl;
 
-import java.util.concurrent.Executor;
-
 import kotlinx.coroutines.CoroutineDispatcher;
 import kotlinx.coroutines.ExecutorsKt;
 
+import org.jspecify.annotations.NonNull;
+
+import java.util.concurrent.Executor;
+
 /**
  * Default Task Executor for executing common tasks in WorkManager
  */
@@ -54,20 +55,17 @@
     };
 
     @Override
-    @NonNull
-    public Executor getMainThreadExecutor() {
+    public @NonNull Executor getMainThreadExecutor() {
         return mMainThreadExecutor;
     }
 
     @Override
-    @NonNull
-    public SerialExecutorImpl getSerialTaskExecutor() {
+    public @NonNull SerialExecutorImpl getSerialTaskExecutor() {
         return mBackgroundExecutor;
     }
 
-    @NonNull
     @Override
-    public CoroutineDispatcher getTaskCoroutineDispatcher() {
+    public @NonNull CoroutineDispatcher getTaskCoroutineDispatcher() {
         return mTaskDispatcher;
     }
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkContinuation.java b/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkContinuation.java
index 8df1b19..a17fdc9 100644
--- a/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkContinuation.java
+++ b/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkContinuation.java
@@ -16,12 +16,13 @@
 
 package androidx.work.multiprocess;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.OneTimeWorkRequest;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Collections;
 import java.util.List;
 
@@ -58,8 +59,7 @@
      * @return A {@link RemoteWorkContinuation} that allows for further chaining of dependent
      *         {@link OneTimeWorkRequest}s
      */
-    @NonNull
-    public abstract RemoteWorkContinuation then(@NonNull List<OneTimeWorkRequest> work);
+    public abstract @NonNull RemoteWorkContinuation then(@NonNull List<OneTimeWorkRequest> work);
 
     /**
      * Enqueues the instance of {@link RemoteWorkContinuation} on the background thread.
@@ -67,8 +67,7 @@
      * @return An {@link ListenableFuture} that can be used to determine when the enqueue
      * has completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> enqueue();
+    public abstract @NonNull ListenableFuture<Void> enqueue();
 
     /**
      * Combines multiple {@link RemoteWorkContinuation}s as prerequisites for a new
@@ -78,8 +77,7 @@
      *                      prerequisites for the return value
      * @return A {@link RemoteWorkContinuation} that allows further chaining
      */
-    @NonNull
-    public static RemoteWorkContinuation combine(
+    public static @NonNull RemoteWorkContinuation combine(
             @NonNull List<RemoteWorkContinuation> continuations) {
 
         return continuations.get(0).combineInternal(continuations);
@@ -88,7 +86,6 @@
     /**
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @NonNull
-    protected abstract RemoteWorkContinuation combineInternal(
+    protected abstract @NonNull RemoteWorkContinuation combineInternal(
             @NonNull List<RemoteWorkContinuation> continuations);
 }
diff --git a/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkManager.java b/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkManager.java
index 9d5e698..e48c9bd 100644
--- a/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkManager.java
+++ b/work/work-runtime/src/main/java/androidx/work/multiprocess/RemoteWorkManager.java
@@ -18,7 +18,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.ExistingPeriodicWorkPolicy;
@@ -36,6 +35,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.UUID;
@@ -59,8 +60,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> enqueue(@NonNull WorkRequest request);
+    public abstract @NonNull ListenableFuture<Void> enqueue(@NonNull WorkRequest request);
 
     /**
      * Enqueues one or more items for background processing.
@@ -69,8 +69,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> enqueue(@NonNull List<WorkRequest> requests);
+    public abstract @NonNull ListenableFuture<Void> enqueue(@NonNull List<WorkRequest> requests);
 
     /**
      * This method allows you to enqueue {@code work} requests to a uniquely-named
@@ -91,8 +90,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
-    public final ListenableFuture<Void> enqueueUniqueWork(
+    public final @NonNull ListenableFuture<Void> enqueueUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull OneTimeWorkRequest work) {
@@ -121,8 +119,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> enqueueUniqueWork(
+    public abstract @NonNull ListenableFuture<Void> enqueueUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull List<OneTimeWorkRequest> work);
@@ -145,8 +142,7 @@
      * @return An {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> enqueueUniquePeriodicWork(
+    public abstract @NonNull ListenableFuture<Void> enqueueUniquePeriodicWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingPeriodicWorkPolicy existingPeriodicWorkPolicy,
             @NonNull PeriodicWorkRequest periodicWork);
@@ -162,8 +158,7 @@
      * @return A {@link RemoteWorkContinuation} that allows for further chaining of dependent
      * {@link OneTimeWorkRequest}
      */
-    @NonNull
-    public final RemoteWorkContinuation beginWith(@NonNull OneTimeWorkRequest work) {
+    public final @NonNull RemoteWorkContinuation beginWith(@NonNull OneTimeWorkRequest work) {
         return beginWith(Collections.singletonList(work));
     }
 
@@ -178,8 +173,8 @@
      * @return A {@link RemoteWorkContinuation} that allows for further chaining of dependent
      * {@link OneTimeWorkRequest}
      */
-    @NonNull
-    public abstract RemoteWorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work);
+    public abstract @NonNull RemoteWorkContinuation beginWith(
+            @NonNull List<OneTimeWorkRequest> work);
 
     /**
      * This method allows you to begin unique chains of work for situations where you only want one
@@ -208,8 +203,7 @@
      *             leaf nodes labelled with {@code uniqueWorkName}.
      * @return A {@link RemoteWorkContinuation} that allows further chaining
      */
-    @NonNull
-    public final RemoteWorkContinuation beginUniqueWork(
+    public final @NonNull RemoteWorkContinuation beginUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull OneTimeWorkRequest work) {
@@ -243,8 +237,7 @@
      *             as a child of all leaf nodes labelled with {@code uniqueWorkName}.
      * @return A {@link RemoteWorkContinuation} that allows further chaining
      */
-    @NonNull
-    public abstract RemoteWorkContinuation beginUniqueWork(
+    public abstract @NonNull RemoteWorkContinuation beginUniqueWork(
             @NonNull String uniqueWorkName,
             @NonNull ExistingWorkPolicy existingWorkPolicy,
             @NonNull List<OneTimeWorkRequest> work);
@@ -255,9 +248,8 @@
      * @return A {@link ListenableFuture} that can be used to determine when the enqueue has
      * completed
      */
-    @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public abstract ListenableFuture<Void> enqueue(@NonNull WorkContinuation continuation);
+    public abstract @NonNull ListenableFuture<Void> enqueue(@NonNull WorkContinuation continuation);
 
     /**
      * Cancels work with the given id if it isn't finished.  Note that cancellation is a best-effort
@@ -268,8 +260,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the cancelWorkById has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> cancelWorkById(@NonNull UUID id);
+    public abstract @NonNull ListenableFuture<Void> cancelWorkById(@NonNull UUID id);
 
     /**
      * Cancels all unfinished work with the given tag.  Note that cancellation is a best-effort
@@ -280,8 +271,7 @@
      * @return An {@link ListenableFuture} that can be used to determine when the
      * cancelAllWorkByTag has completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> cancelAllWorkByTag(@NonNull String tag);
+    public abstract @NonNull ListenableFuture<Void> cancelAllWorkByTag(@NonNull String tag);
 
     /**
      * Cancels all unfinished work in the work chain with the given name.  Note that cancellation is
@@ -292,8 +282,8 @@
      * @return A {@link ListenableFuture} that can be used to determine when the cancelUniqueWork
      * has completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> cancelUniqueWork(@NonNull String uniqueWorkName);
+    public abstract @NonNull ListenableFuture<Void> cancelUniqueWork(
+            @NonNull String uniqueWorkName);
 
     /**
      * Cancels all unfinished work.  <b>Use this method with extreme caution!</b>  By invoking it,
@@ -306,8 +296,7 @@
      * @return A {@link ListenableFuture} that can be used to determine when the cancelAllWork has
      * completed
      */
-    @NonNull
-    public abstract ListenableFuture<Void> cancelAllWork();
+    public abstract @NonNull ListenableFuture<Void> cancelAllWork();
 
     /**
      * Gets the {@link ListenableFuture} of the {@link List} of {@link WorkInfo} for all work
@@ -317,8 +306,8 @@
      * @return A {@link ListenableFuture} of the {@link List} of {@link WorkInfo} for work
      * referenced by this {@link WorkQuery}.
      */
-    @NonNull
-    public abstract ListenableFuture<List<WorkInfo>> getWorkInfos(@NonNull WorkQuery workQuery);
+    public abstract @NonNull ListenableFuture<List<WorkInfo>> getWorkInfos(
+            @NonNull WorkQuery workQuery);
 
     /**
      * Updates progress information for a {@link ListenableWorker}.
@@ -328,9 +317,9 @@
      * @return A {@link ListenableFuture} that can be used to determine when the setProgress
      * has completed.
      */
-    @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public abstract ListenableFuture<Void> setProgress(@NonNull UUID id, @NonNull Data data);
+    public abstract @NonNull ListenableFuture<Void> setProgress(@NonNull UUID id,
+            @NonNull Data data);
 
     /**
      * Delegates the call to {@link ListenableWorker#setForegroundAsync(ForegroundInfo)} to the
@@ -341,9 +330,8 @@
      * @return A {@link ListenableFuture} that can be used to determine when the setForeground
      * has completed.
      */
-    @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public abstract ListenableFuture<Void> setForegroundAsync(
+    public abstract @NonNull ListenableFuture<Void> setForegroundAsync(
             @NonNull String id,
             @NonNull ForegroundInfo foregroundInfo);
 
@@ -354,8 +342,7 @@
      * @param context The application context.
      * @return The instance of {@link RemoteWorkManager}.
      */
-    @NonNull
-    public static RemoteWorkManager getInstance(@NonNull Context context) {
+    public static @NonNull RemoteWorkManager getInstance(@NonNull Context context) {
         WorkManagerImpl workManager = WorkManagerImpl.getInstance(context);
         RemoteWorkManager remoteWorkManager = workManager.getRemoteWorkManager();
         if (remoteWorkManager == null) {
diff --git a/work/work-runtime/src/test/java/androidx/work/DataTest.java b/work/work-runtime/src/test/java/androidx/work/DataTest.java
index ff8736d..5322c17 100644
--- a/work/work-runtime/src/test/java/androidx/work/DataTest.java
+++ b/work/work-runtime/src/test/java/androidx/work/DataTest.java
@@ -23,8 +23,7 @@
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 
-import androidx.annotation.NonNull;
-
+import org.jspecify.annotations.NonNull;
 import org.junit.Test;
 
 import java.util.HashMap;
@@ -239,8 +238,7 @@
         assertThat(caughtIllegalArgumentException, is(true));
     }
 
-    @NonNull
-    private Data createData() {
+    private @NonNull Data createData() {
         Map<String, Object> map = new HashMap<>();
         map.put("boolean", true);
         map.put("byte", (byte) 1);
diff --git a/work/work-rxjava2/build.gradle b/work/work-rxjava2/build.gradle
index 2753a93..bfe3012 100644
--- a/work/work-rxjava2/build.gradle
+++ b/work/work-rxjava2/build.gradle
@@ -30,6 +30,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api(project(":work:work-runtime"))
     api(libs.rxjava2)
     implementation("androidx.concurrent:concurrent-futures:1.1.0")
@@ -46,8 +47,6 @@
     description = "Android WorkManager RxJava2 interoperatibility library"
     failOnDeprecationWarnings = false
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/work/work-rxjava2/src/main/java/androidx/work/RxWorker.java b/work/work-rxjava2/src/main/java/androidx/work/RxWorker.java
index 7c44317..3d3500a 100644
--- a/work/work-rxjava2/src/main/java/androidx/work/RxWorker.java
+++ b/work/work-rxjava2/src/main/java/androidx/work/RxWorker.java
@@ -21,7 +21,6 @@
 import android.content.Context;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
 import androidx.work.impl.utils.SynchronousExecutor;
 
 import com.google.common.util.concurrent.ListenableFuture;
@@ -33,6 +32,8 @@
 import io.reactivex.disposables.Disposable;
 import io.reactivex.schedulers.Schedulers;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -63,9 +64,8 @@
         super(appContext, workerParams);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Result> startWork() {
+    public @NonNull ListenableFuture<Result> startWork() {
         return convert(createWork());
     }
 
@@ -120,9 +120,8 @@
      * <p>
      * Use {@link #setCompletableProgress(Data)} instead.
      */
-    @NonNull
     @Deprecated
-    public final Single<Void> setProgress(@NonNull Data data) {
+    public final @NonNull Single<Void> setProgress(@NonNull Data data) {
         return Single.fromFuture(setProgressAsync(data));
     }
 
@@ -133,14 +132,12 @@
      * @param data The progress {@link Data}
      * @return The {@link Completable}
      */
-    @NonNull
-    public final Completable setCompletableProgress(@NonNull Data data) {
+    public final @NonNull Completable setCompletableProgress(@NonNull Data data) {
         return Completable.fromFuture(setProgressAsync(data));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
+    public @NonNull ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
         return convert(getForegroundInfo());
     }
 
@@ -161,8 +158,7 @@
      * is marked immediate. For more information look at
      * {@link WorkRequest.Builder#setExpedited(OutOfQuotaPolicy)}.
      */
-    @NonNull
-    public Single<ForegroundInfo> getForegroundInfo() {
+    public @NonNull Single<ForegroundInfo> getForegroundInfo() {
         String message =
                 "Expedited WorkRequests require a RxWorker to provide an implementation for"
                         + " `getForegroundInfo()`";
@@ -191,8 +187,7 @@
      * @return A {@link Completable} which resolves after the {@link RxWorker}
      * transitions to running in the context of a foreground {@link android.app.Service}.
      */
-    @NonNull
-    public final Completable setForeground(@NonNull ForegroundInfo foregroundInfo) {
+    public final @NonNull Completable setForeground(@NonNull ForegroundInfo foregroundInfo) {
         return Completable.fromFuture(setForegroundAsync(foregroundInfo));
     }
 
diff --git a/work/work-rxjava3/build.gradle b/work/work-rxjava3/build.gradle
index e592631..dec427d 100644
--- a/work/work-rxjava3/build.gradle
+++ b/work/work-rxjava3/build.gradle
@@ -30,6 +30,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api(project(":work:work-runtime"))
     api(libs.rxjava3)
     implementation("androidx.concurrent:concurrent-futures:1.1.0")
@@ -45,8 +46,6 @@
     inceptionYear = "2020"
     description = "Android WorkManager RxJava3 interoperatibility library"
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/work/work-rxjava3/src/main/java/androidx/work/rxjava3/RxWorker.java b/work/work-rxjava3/src/main/java/androidx/work/rxjava3/RxWorker.java
index c5e8574..b631459 100644
--- a/work/work-rxjava3/src/main/java/androidx/work/rxjava3/RxWorker.java
+++ b/work/work-rxjava3/src/main/java/androidx/work/rxjava3/RxWorker.java
@@ -21,7 +21,6 @@
 import android.content.Context;
 
 import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
 import androidx.work.Configuration;
 import androidx.work.Data;
 import androidx.work.ForegroundInfo;
@@ -42,6 +41,8 @@
 import io.reactivex.rxjava3.disposables.Disposable;
 import io.reactivex.rxjava3.schedulers.Schedulers;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -72,9 +73,8 @@
         super(appContext, workerParams);
     }
 
-    @NonNull
     @Override
-    public final ListenableFuture<Result> startWork() {
+    public final @NonNull ListenableFuture<Result> startWork() {
         return convert(createWork());
     }
 
@@ -123,14 +123,12 @@
      * @param data The progress {@link Data}
      * @return The {@link Completable}
      */
-    @NonNull
-    public final Completable setCompletableProgress(@NonNull Data data) {
+    public final @NonNull Completable setCompletableProgress(@NonNull Data data) {
         return Completable.fromFuture(setProgressAsync(data));
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
+    public @NonNull ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
         return convert(getForegroundInfo());
     }
 
@@ -151,8 +149,7 @@
      * is marked immediate. For more information look at
      * {@link WorkRequest.Builder#setExpedited(OutOfQuotaPolicy)}.
      */
-    @NonNull
-    public Single<ForegroundInfo> getForegroundInfo() {
+    public @NonNull Single<ForegroundInfo> getForegroundInfo() {
         String message =
                 "Expedited WorkRequests require a RxWorker to provide an implementation for"
                         + " `getForegroundInfo()`";
@@ -181,8 +178,7 @@
      * @return A {@link Completable} which resolves after the {@link RxWorker}
      * transitions to running in the context of a foreground {@link android.app.Service}.
      */
-    @NonNull
-    public final Completable setForeground(@NonNull ForegroundInfo foregroundInfo) {
+    public final @NonNull Completable setForeground(@NonNull ForegroundInfo foregroundInfo) {
         return Completable.fromFuture(setForegroundAsync(foregroundInfo));
     }
 
diff --git a/work/work-testing/build.gradle b/work/work-testing/build.gradle
index 446e53b..7e946d1 100644
--- a/work/work-testing/build.gradle
+++ b/work/work-testing/build.gradle
@@ -30,6 +30,7 @@
 }
 
 dependencies {
+    api(libs.jspecify)
     api(project(":work:work-runtime"))
 
     implementation("androidx.lifecycle:lifecycle-livedata-core:2.6.2")
@@ -59,8 +60,6 @@
     inceptionYear = "2018"
     description = "Android WorkManager testing library"
     legacyDisableKotlinStrictApiMode = true
-    // TODO: b/326456246
-    optOutJSpecify = true
 }
 
 android {
diff --git a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/CountingTestWorker.java b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/CountingTestWorker.java
index b07246d..d274251 100644
--- a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/CountingTestWorker.java
+++ b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/CountingTestWorker.java
@@ -18,10 +18,11 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -35,9 +36,8 @@
         super(context, workerParams);
     }
 
-    @NonNull
     @Override
-    public Result doWork() {
+    public @NonNull Result doWork() {
         COUNT.incrementAndGet();
         return Result.success();
     }
diff --git a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestListenableWorker.java b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestListenableWorker.java
index c47d444..5ef48c3 100644
--- a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestListenableWorker.java
+++ b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestListenableWorker.java
@@ -18,13 +18,14 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
 import androidx.work.ListenableWorker;
 import androidx.work.WorkerParameters;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 public class TestListenableWorker extends ListenableWorker {
     public TestListenableWorker(
             @NonNull Context context,
@@ -32,9 +33,8 @@
         super(context, workerParameters);
     }
 
-    @NonNull
     @Override
-    public ListenableFuture<Result> startWork() {
+    public @NonNull ListenableFuture<Result> startWork() {
         return CallbackToFutureAdapter.getFuture(completer -> {
             completer.set(Result.success());
             return "successfully completed future";
diff --git a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestWorker.java b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestWorker.java
index 2423096..6f56ac0 100644
--- a/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestWorker.java
+++ b/work/work-testing/src/androidTest/java/androidx/work/testing/workers/TestWorker.java
@@ -19,11 +19,12 @@
 import android.content.Context;
 import android.util.Log;
 
-import androidx.annotation.NonNull;
 import androidx.work.Logger;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 
+import org.jspecify.annotations.NonNull;
+
 /**
  * A test {@link Worker} that prints a log and returns a successful result.
  */
diff --git a/work/work-testing/src/main/java/androidx/work/testing/InstantWorkTaskExecutor.java b/work/work-testing/src/main/java/androidx/work/testing/InstantWorkTaskExecutor.java
index 27f7548..d44f819 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/InstantWorkTaskExecutor.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/InstantWorkTaskExecutor.java
@@ -16,12 +16,13 @@
 
 package androidx.work.testing;
 
-import androidx.annotation.NonNull;
 import androidx.work.impl.utils.SerialExecutorImpl;
 import androidx.work.impl.utils.SynchronousExecutor;
 import androidx.work.impl.utils.taskexecutor.SerialExecutor;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.concurrent.Executor;
 
 /**
@@ -32,20 +33,17 @@
     private Executor mSynchronousExecutor = new SynchronousExecutor();
     private SerialExecutorImpl mSerialExecutor = new SerialExecutorImpl(mSynchronousExecutor);
 
-    @NonNull
-    Executor getSynchronousExecutor() {
+    @NonNull Executor getSynchronousExecutor() {
         return mSynchronousExecutor;
     }
 
-    @NonNull
     @Override
-    public Executor getMainThreadExecutor() {
+    public @NonNull Executor getMainThreadExecutor() {
         return mSynchronousExecutor;
     }
 
-    @NonNull
     @Override
-    public SerialExecutor getSerialTaskExecutor() {
+    public @NonNull SerialExecutor getSerialTaskExecutor() {
         return mSerialExecutor;
     }
 }
diff --git a/work/work-testing/src/main/java/androidx/work/testing/SynchronousExecutor.java b/work/work-testing/src/main/java/androidx/work/testing/SynchronousExecutor.java
index 4d6612c..1718c13 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/SynchronousExecutor.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/SynchronousExecutor.java
@@ -16,7 +16,7 @@
 
 package androidx.work.testing;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.concurrent.Executor;
 
diff --git a/work/work-testing/src/main/java/androidx/work/testing/TestDriver.java b/work/work-testing/src/main/java/androidx/work/testing/TestDriver.java
index 7295f11c..b395fdb 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/TestDriver.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/TestDriver.java
@@ -16,7 +16,7 @@
 
 package androidx.work.testing;
 
-import androidx.annotation.NonNull;
+import org.jspecify.annotations.NonNull;
 
 import java.util.UUID;
 
diff --git a/work/work-testing/src/main/java/androidx/work/testing/TestForegroundUpdater.java b/work/work-testing/src/main/java/androidx/work/testing/TestForegroundUpdater.java
index 2835e98..79b0c26 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/TestForegroundUpdater.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/TestForegroundUpdater.java
@@ -20,7 +20,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.ForegroundInfo;
 import androidx.work.ForegroundUpdater;
@@ -28,6 +27,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -38,9 +39,8 @@
 
     private static final String TAG = Logger.tagWithPrefix("TestForegroundUpdater");
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> setForegroundAsync(
+    public @NonNull ListenableFuture<Void> setForegroundAsync(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull ForegroundInfo foregroundInfo) {
diff --git a/work/work-testing/src/main/java/androidx/work/testing/TestListenableWorkerBuilder.java b/work/work-testing/src/main/java/androidx/work/testing/TestListenableWorkerBuilder.java
index c06abc9..e9c235d 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/TestListenableWorkerBuilder.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/TestListenableWorkerBuilder.java
@@ -22,7 +22,6 @@
 import android.net.Uri;
 
 import androidx.annotation.IntRange;
-import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
@@ -36,14 +35,16 @@
 import androidx.work.impl.model.WorkSpec;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 
+import kotlinx.coroutines.Dispatchers;
+
+import org.jspecify.annotations.NonNull;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.UUID;
 import java.util.concurrent.Executor;
 
-import kotlinx.coroutines.Dispatchers;
-
 /**
  * Builds instances of {@link androidx.work.ListenableWorker} which can be used for testing.
  *
@@ -86,8 +87,7 @@
     /**
      * @return The application {@link Context}.
      */
-    @NonNull
-    Context getApplicationContext() {
+    @NonNull Context getApplicationContext() {
         return mContext;
     }
 
@@ -101,8 +101,7 @@
     /**
      * @return The {@link String} name of the unit of work.
      */
-    @NonNull
-    String getWorkerName() {
+    @NonNull String getWorkerName() {
         return mWorkerName;
     }
 
@@ -110,8 +109,7 @@
      * @return The {@link androidx.work.WorkerParameters.RuntimeExtras} associated with this unit
      * of work.
      */
-    @NonNull
-    WorkerParameters.RuntimeExtras getRuntimeExtras() {
+    WorkerParameters.@NonNull RuntimeExtras getRuntimeExtras() {
         return mRuntimeExtras;
     }
 
@@ -119,34 +117,30 @@
      * @return The {@link TaskExecutor} associated with this unit of work.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @NonNull
-    TaskExecutor getTaskExecutor() {
+    @NonNull TaskExecutor getTaskExecutor() {
         return mTaskExecutor;
     }
 
     /**
      * @return The {@link TaskExecutor} associated with this unit of work.
      */
-    @NonNull
-    Executor getExecutor() {
+    @NonNull Executor getExecutor() {
         return mExecutor;
     }
 
     /**
      * @return The {@link ProgressUpdater} associated with this unit of work.
      */
-    @NonNull
     @SuppressWarnings({"KotlinPropertyAccess", "WeakerAccess"})
-    ProgressUpdater getProgressUpdater() {
+    @NonNull ProgressUpdater getProgressUpdater() {
         return mProgressUpdater;
     }
 
     /**
      * @return The {@link ForegroundUpdater} associated with this unit of work.
      */
-    @NonNull
     @SuppressLint("KotlinPropertyAccess")
-    ForegroundUpdater getForegroundUpdater() {
+    @NonNull ForegroundUpdater getForegroundUpdater() {
         return mForegroundUpdater;
     }
 
@@ -156,8 +150,7 @@
      * @param id The {@link UUID}
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setId(@NonNull UUID id) {
+    public @NonNull TestListenableWorkerBuilder<W> setId(@NonNull UUID id) {
         mId = id;
         return this;
     }
@@ -168,8 +161,7 @@
      * @param inputData key/value pairs that will be provided to the worker
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setInputData(@NonNull Data inputData) {
+    public @NonNull TestListenableWorkerBuilder<W> setInputData(@NonNull Data inputData) {
         mInputData = inputData;
         return this;
     }
@@ -180,8 +172,7 @@
      * @param tags The {@link List} of tags to be used
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setTags(@NonNull List<String> tags) {
+    public @NonNull TestListenableWorkerBuilder<W> setTags(@NonNull List<String> tags) {
         mTags = tags;
         return this;
     }
@@ -192,8 +183,7 @@
      * @param runAttemptCount The initial run attempt count
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setRunAttemptCount(int runAttemptCount) {
+    public @NonNull TestListenableWorkerBuilder<W> setRunAttemptCount(int runAttemptCount) {
         mRunAttemptCount = runAttemptCount;
         return this;
     }
@@ -205,8 +195,8 @@
      * @return The current {@link TestListenableWorkerBuilder}
      */
     @RequiresApi(24)
-    @NonNull
-    public TestListenableWorkerBuilder<W> setTriggeredContentUris(@NonNull List<Uri> contentUris) {
+    public @NonNull TestListenableWorkerBuilder<W> setTriggeredContentUris(
+            @NonNull List<Uri> contentUris) {
         mRuntimeExtras.triggeredContentUris = contentUris;
         return this;
     }
@@ -218,8 +208,7 @@
      * @return The current {@link TestListenableWorkerBuilder}
      */
     @RequiresApi(24)
-    @NonNull
-    public TestListenableWorkerBuilder<W> setTriggeredContentAuthorities(
+    public @NonNull TestListenableWorkerBuilder<W> setTriggeredContentAuthorities(
             @NonNull List<String> authorities) {
         mRuntimeExtras.triggeredContentAuthorities = authorities;
         return this;
@@ -232,8 +221,7 @@
      * @return The current {@link TestListenableWorkerBuilder}
      */
     @RequiresApi(28)
-    @NonNull
-    public TestListenableWorkerBuilder<W> setNetwork(@NonNull Network network) {
+    public @NonNull TestListenableWorkerBuilder<W> setNetwork(@NonNull Network network) {
         mRuntimeExtras.network = network;
         return this;
     }
@@ -246,8 +234,8 @@
      *                      {@link androidx.work.ListenableWorker}
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setWorkerFactory(@NonNull WorkerFactory workerFactory) {
+    public @NonNull TestListenableWorkerBuilder<W> setWorkerFactory(
+            @NonNull WorkerFactory workerFactory) {
         mWorkerFactory = workerFactory;
         return this;
     }
@@ -259,8 +247,8 @@
      * @param updater The {@link ProgressUpdater} which can handle progress updates.
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setProgressUpdater(@NonNull ProgressUpdater updater) {
+    public @NonNull TestListenableWorkerBuilder<W> setProgressUpdater(
+            @NonNull ProgressUpdater updater) {
         mProgressUpdater = updater;
         return this;
     }
@@ -272,8 +260,7 @@
      * @param updater The {@link ForegroundUpdater} which can handle notification updates.
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    public TestListenableWorkerBuilder<W> setForegroundUpdater(
+    public @NonNull TestListenableWorkerBuilder<W> setForegroundUpdater(
             @NonNull ForegroundUpdater updater) {
         mForegroundUpdater = updater;
         return this;
@@ -285,8 +272,7 @@
      * @param executor The {@link Executor}
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    TestListenableWorkerBuilder<W> setExecutor(@NonNull Executor executor) {
+    @NonNull TestListenableWorkerBuilder<W> setExecutor(@NonNull Executor executor) {
         mExecutor = executor;
         return this;
     }
@@ -299,8 +285,7 @@
      * @param generation generation
      * @return The current {@link TestListenableWorkerBuilder}
      */
-    @NonNull
-    TestListenableWorkerBuilder<W> setGeneration(@IntRange(from = 0) int generation) {
+    @NonNull TestListenableWorkerBuilder<W> setGeneration(@IntRange(from = 0) int generation) {
         mGeneration = generation;
         return this;
     }
@@ -310,9 +295,8 @@
      *
      * @return the instance of a {@link ListenableWorker}.
      */
-    @NonNull
     @SuppressWarnings("unchecked")
-    public W build() {
+    public @NonNull W build() {
         WorkerParameters parameters =
                 new WorkerParameters(
                         mId,
@@ -354,9 +338,8 @@
      * @param workRequest The {@link WorkRequest}
      * @return The new instance of a {@link ListenableWorker}
      */
-    @NonNull
     @SuppressWarnings("unchecked")
-    public static TestListenableWorkerBuilder<? extends ListenableWorker> from(
+    public static @NonNull TestListenableWorkerBuilder<? extends ListenableWorker> from(
             @NonNull Context context,
             @NonNull WorkRequest workRequest) {
         WorkSpec workSpec = workRequest.getWorkSpec();
@@ -381,8 +364,7 @@
      * @param workerClass The subtype of {@link ListenableWorker} being built
      * @return The new instance of a {@link ListenableWorker}
      */
-    @NonNull
-    public static <W extends ListenableWorker> TestListenableWorkerBuilder<W> from(
+    public static <W extends ListenableWorker> @NonNull TestListenableWorkerBuilder<W> from(
             @NonNull Context context,
             @NonNull Class<W> workerClass) {
         return new TestListenableWorkerBuilder<>(context, workerClass);
diff --git a/work/work-testing/src/main/java/androidx/work/testing/TestProgressUpdater.java b/work/work-testing/src/main/java/androidx/work/testing/TestProgressUpdater.java
index 161bfad..ed521af 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/TestProgressUpdater.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/TestProgressUpdater.java
@@ -20,7 +20,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 import androidx.work.Data;
 import androidx.work.Logger;
@@ -28,6 +27,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jspecify.annotations.NonNull;
+
 import java.util.UUID;
 
 /**
@@ -37,9 +38,8 @@
 public class TestProgressUpdater implements ProgressUpdater {
     private static final String TAG = Logger.tagWithPrefix("TestProgressUpdater");
 
-    @NonNull
     @Override
-    public ListenableFuture<Void> updateProgress(
+    public @NonNull ListenableFuture<Void> updateProgress(
             @NonNull Context context,
             @NonNull UUID id,
             @NonNull Data data) {
diff --git a/work/work-testing/src/main/java/androidx/work/testing/TestWorkerBuilder.java b/work/work-testing/src/main/java/androidx/work/testing/TestWorkerBuilder.java
index cdb50af..75f899b 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/TestWorkerBuilder.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/TestWorkerBuilder.java
@@ -18,12 +18,13 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.work.WorkRequest;
 import androidx.work.Worker;
 import androidx.work.impl.model.WorkSpec;
 
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Executor;
@@ -54,9 +55,8 @@
      * @param executor    The {@link Executor}
      * @return The new instance of a {@link TestWorkerBuilder}
      */
-    @NonNull
     @SuppressWarnings("unchecked")
-    public static TestWorkerBuilder<? extends Worker> from(
+    public static @NonNull TestWorkerBuilder<? extends Worker> from(
             @NonNull Context context,
             @NonNull WorkRequest workRequest,
             @NonNull Executor executor) {
@@ -91,8 +91,7 @@
      * @param executor    The {@link Executor}
      * @return The new instance of a {@link TestWorkerBuilder}
      */
-    @NonNull
-    public static <W extends Worker> TestWorkerBuilder<W> from(
+    public static <W extends Worker> @NonNull TestWorkerBuilder<W> from(
             @NonNull Context context,
             @NonNull Class<W> workerClass,
             @NonNull Executor executor) {
diff --git a/work/work-testing/src/main/java/androidx/work/testing/WorkManagerTestInitHelper.java b/work/work-testing/src/main/java/androidx/work/testing/WorkManagerTestInitHelper.java
index 4bd0a08..e44663c 100644
--- a/work/work-testing/src/main/java/androidx/work/testing/WorkManagerTestInitHelper.java
+++ b/work/work-testing/src/main/java/androidx/work/testing/WorkManagerTestInitHelper.java
@@ -21,15 +21,15 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.work.Configuration;
 import androidx.work.impl.WorkManagerImpl;
 import androidx.work.impl.utils.SerialExecutorImpl;
 import androidx.work.impl.utils.taskexecutor.SerialExecutor;
 
-import java.util.UUID;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 
+import java.util.UUID;
 
 /**
  * Helps initialize {@link androidx.work.WorkManager} for testing.
diff --git a/xr/OWNERS b/xr/OWNERS
new file mode 100644
index 0000000..d8768a3
--- /dev/null
+++ b/xr/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1524104
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/arcore/arcore/api/current.txt
similarity index 100%
rename from camera/camera-effects-still-portrait/api/current.txt
rename to xr/arcore/arcore/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/arcore/arcore/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/arcore/arcore/api/res-current.txt
diff --git a/xr/arcore/arcore/api/restricted_current.txt b/xr/arcore/arcore/api/restricted_current.txt
new file mode 100644
index 0000000..fcdf076
--- /dev/null
+++ b/xr/arcore/arcore/api/restricted_current.txt
@@ -0,0 +1,148 @@
+// Signature format: 4.0
+package androidx.xr.arcore {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Anchor {
+    ctor public Anchor(androidx.xr.runtime.internal.Anchor runtimeAnchor);
+    method public static androidx.xr.arcore.AnchorCreateResult create(androidx.xr.runtime.Session session, androidx.xr.runtime.math.Pose pose);
+    method public void detach();
+    method public static java.util.List<java.util.UUID> getPersistedAnchorUuids(androidx.xr.runtime.Session session);
+    method public androidx.xr.runtime.internal.Anchor getRuntimeAnchor();
+    method public kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Anchor.State> getState();
+    method public static androidx.xr.arcore.AnchorCreateResult load(androidx.xr.runtime.Session session, java.util.UUID uuid);
+    method public static androidx.xr.arcore.Anchor loadFromNativePointer(androidx.xr.runtime.Session session, long nativePointer);
+    method public suspend Object? persist(kotlin.coroutines.Continuation<? super java.util.UUID>);
+    method public static void unpersist(androidx.xr.runtime.Session session, java.util.UUID uuid);
+    method public suspend Object? update(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final androidx.xr.runtime.internal.Anchor runtimeAnchor;
+    property public final kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Anchor.State> state;
+    field public static final androidx.xr.arcore.Anchor.Companion Companion;
+  }
+
+  public static final class Anchor.Companion {
+    method public androidx.xr.arcore.AnchorCreateResult create(androidx.xr.runtime.Session session, androidx.xr.runtime.math.Pose pose);
+    method public java.util.List<java.util.UUID> getPersistedAnchorUuids(androidx.xr.runtime.Session session);
+    method public androidx.xr.arcore.AnchorCreateResult load(androidx.xr.runtime.Session session, java.util.UUID uuid);
+    method public androidx.xr.arcore.Anchor loadFromNativePointer(androidx.xr.runtime.Session session, long nativePointer);
+    method public void unpersist(androidx.xr.runtime.Session session, java.util.UUID uuid);
+  }
+
+  public static final class Anchor.State {
+    ctor public Anchor.State(androidx.xr.runtime.internal.TrackingState trackingState, androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    property public final androidx.xr.runtime.math.Pose pose;
+    property public final androidx.xr.runtime.internal.TrackingState trackingState;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class AnchorCreateResourcesExhausted extends androidx.xr.arcore.AnchorCreateResult {
+    ctor public AnchorCreateResourcesExhausted();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class AnchorCreateResult {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class AnchorCreateSuccess extends androidx.xr.arcore.AnchorCreateResult {
+    ctor public AnchorCreateSuccess(androidx.xr.arcore.Anchor anchor);
+    method public androidx.xr.arcore.Anchor getAnchor();
+    property public final androidx.xr.arcore.Anchor anchor;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class HitResult {
+    method public androidx.xr.arcore.Anchor createAnchor();
+    method public float getDistance();
+    method public androidx.xr.runtime.math.Pose getHitPose();
+    method public androidx.xr.arcore.Trackable<androidx.xr.arcore.Trackable.State> getTrackable();
+    property public final float distance;
+    property public final androidx.xr.runtime.math.Pose hitPose;
+    property public final androidx.xr.arcore.Trackable<androidx.xr.arcore.Trackable.State> trackable;
+  }
+
+  public final class Interaction {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static java.util.List<androidx.xr.arcore.HitResult> hitTest(androidx.xr.runtime.Session session, androidx.xr.runtime.math.Ray ray);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PerceptionState {
+    method public kotlin.time.ComparableTimeMark getTimeMark();
+    method public java.util.Collection<androidx.xr.arcore.Trackable<androidx.xr.arcore.Trackable.State>> getTrackables();
+    property public final kotlin.time.ComparableTimeMark timeMark;
+    property public final java.util.Collection<androidx.xr.arcore.Trackable<androidx.xr.arcore.Trackable.State>> trackables;
+  }
+
+  public final class PerceptionStateExtenderKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.arcore.PerceptionState? getPerceptionState(androidx.xr.runtime.CoreState);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Plane implements androidx.xr.arcore.Trackable<androidx.xr.arcore.Plane.State> {
+    method public androidx.xr.arcore.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Plane.State> getState();
+    method public androidx.xr.arcore.Plane.Type getType();
+    method public static kotlinx.coroutines.flow.StateFlow<java.util.Collection<androidx.xr.arcore.Plane>> subscribe(androidx.xr.runtime.Session session);
+    method public suspend Object? update(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Plane.State> state;
+    property public final androidx.xr.arcore.Plane.Type type;
+    field public static final androidx.xr.arcore.Plane.Companion Companion;
+  }
+
+  public static final class Plane.Companion {
+    method public kotlinx.coroutines.flow.StateFlow<java.util.Collection<androidx.xr.arcore.Plane>> subscribe(androidx.xr.runtime.Session session);
+  }
+
+  public static final class Plane.Label {
+    field public static final androidx.xr.arcore.Plane.Label Ceiling;
+    field public static final androidx.xr.arcore.Plane.Label.Companion Companion;
+    field public static final androidx.xr.arcore.Plane.Label Floor;
+    field public static final androidx.xr.arcore.Plane.Label Table;
+    field public static final androidx.xr.arcore.Plane.Label Unknown;
+    field public static final androidx.xr.arcore.Plane.Label Wall;
+  }
+
+  public static final class Plane.Label.Companion {
+    property public final androidx.xr.arcore.Plane.Label Ceiling;
+    property public final androidx.xr.arcore.Plane.Label Floor;
+    property public final androidx.xr.arcore.Plane.Label Table;
+    property public final androidx.xr.arcore.Plane.Label Unknown;
+    property public final androidx.xr.arcore.Plane.Label Wall;
+  }
+
+  public static final class Plane.State implements androidx.xr.arcore.Trackable.State {
+    ctor public Plane.State(androidx.xr.runtime.internal.TrackingState trackingState, androidx.xr.arcore.Plane.Label label, androidx.xr.runtime.math.Pose centerPose, androidx.xr.runtime.math.Vector2 extents, androidx.xr.arcore.Plane? subsumedBy, java.util.List<androidx.xr.runtime.math.Vector2> vertices);
+    method public androidx.xr.runtime.math.Pose getCenterPose();
+    method public androidx.xr.runtime.math.Vector2 getExtents();
+    method public androidx.xr.arcore.Plane.Label getLabel();
+    method public androidx.xr.arcore.Plane? getSubsumedBy();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public java.util.List<androidx.xr.runtime.math.Vector2> getVertices();
+    property public final androidx.xr.runtime.math.Pose centerPose;
+    property public final androidx.xr.runtime.math.Vector2 extents;
+    property public final androidx.xr.arcore.Plane.Label label;
+    property public final androidx.xr.arcore.Plane? subsumedBy;
+    property public androidx.xr.runtime.internal.TrackingState trackingState;
+    property public final java.util.List<androidx.xr.runtime.math.Vector2> vertices;
+  }
+
+  public static final class Plane.Type {
+    field public static final androidx.xr.arcore.Plane.Type.Companion Companion;
+    field public static final androidx.xr.arcore.Plane.Type HorizontalDownwardFacing;
+    field public static final androidx.xr.arcore.Plane.Type HorizontalUpwardFacing;
+    field public static final androidx.xr.arcore.Plane.Type Vertical;
+  }
+
+  public static final class Plane.Type.Companion {
+    property public final androidx.xr.arcore.Plane.Type HorizontalDownwardFacing;
+    property public final androidx.xr.arcore.Plane.Type HorizontalUpwardFacing;
+    property public final androidx.xr.arcore.Plane.Type Vertical;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Trackable<State> {
+    method public androidx.xr.arcore.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Trackable.State> getState();
+    property public abstract kotlinx.coroutines.flow.StateFlow<androidx.xr.arcore.Trackable.State> state;
+  }
+
+  public static interface Trackable.State {
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    property public abstract androidx.xr.runtime.internal.TrackingState trackingState;
+  }
+
+}
+
diff --git a/xr/arcore/arcore/build.gradle b/xr/arcore/arcore/build.gradle
new file mode 100644
index 0000000..336d97e
--- /dev/null
+++ b/xr/arcore/arcore/build.gradle
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
+    api(project(":xr:runtime:runtime"))
+
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation(project(":xr:runtime:runtime-openxr"))
+
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+    testImplementation(project(":kruth:kruth"))
+    testImplementation(project(":xr:runtime:runtime-testing"))
+    testImplementation("androidx.appcompat:appcompat:1.2.0")
+    testImplementation(libs.testRules)
+}
+
+android {
+    namespace = "androidx.xr.arcore"
+    sourceSets.main.resources.srcDirs += "src/main/resources"
+    testOptions.unitTests.includeAndroidResources = true
+}
+
+androidx {
+    name = "ARCore"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Provides augmented reality (AR) functionality."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt
new file mode 100644
index 0000000..1c8411b
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Anchor.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.internal.Anchor as RuntimeAnchor
+import androidx.xr.runtime.math.Pose
+import java.util.UUID
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/**
+ * An anchor describes a fixed location and orientation in the real world. To stay at a fixed
+ * location in physical space, the numerical description of this position may update as ARCore for
+ * XR updates its understanding of the physical world.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Anchor
+internal constructor(
+    public val runtimeAnchor: RuntimeAnchor,
+    private val xrResourceManager: XrResourcesManager,
+) : Updatable {
+    public companion object {
+        /**
+         * Creates an [Anchor] at the given [pose].
+         *
+         * @param session the [Session] that is used to create the anchor.
+         * @param pose the [Pose] that describes the location and orientation of the anchor.
+         * @return the result of the operation. Can be [AnchorCreateSuccess] that contains the
+         *   created [Anchor], or [AnchorCreateResourcesExhausted] if the resources allocated for
+         *   anchors have been exhausted.
+         */
+        @JvmStatic
+        public fun create(session: Session, pose: Pose): AnchorCreateResult {
+            val perceptionStateExtender = getPerceptionStateExtender(session)
+            val runtimeAnchor = session.runtime.perceptionManager.createAnchor(pose)
+            return generateCreateResult(runtimeAnchor, perceptionStateExtender.xrResourcesManager)
+        }
+
+        /**
+         * Retrieves all the [UUID] instances from [Anchor] objects that have been persisted by
+         * [persist] that are still present in the local storage.
+         */
+        @JvmStatic
+        public fun getPersistedAnchorUuids(session: Session): List<UUID> {
+            return session.runtime.perceptionManager.getPersistedAnchorUuids()
+        }
+
+        /**
+         * Loads an [Anchor] from local storage, using the given [uuid]. The anchor will attempt to
+         * be in the same physical location as the anchor that was previously persisted. The [uuid]
+         * should be the return value of a previous call to [persist].
+         */
+        @JvmStatic
+        public fun load(session: Session, uuid: UUID): AnchorCreateResult {
+            val perceptionStateExtender = getPerceptionStateExtender(session)
+            val runtimeAnchor = session.runtime.perceptionManager.loadAnchor(uuid)
+            return generateCreateResult(runtimeAnchor, perceptionStateExtender.xrResourcesManager)
+        }
+
+        /** Loads an [Anchor] of the given native pointer. */
+        // TODO(b/373711152) : Remove this method once the Jetpack XR Runtime API migration is done.
+        @JvmStatic
+        public fun loadFromNativePointer(session: Session, nativePointer: Long): Anchor {
+            val perceptionStateExtender = getPerceptionStateExtender(session)
+            val runtimeAnchor =
+                session.runtime.perceptionManager.loadAnchorFromNativePointer(nativePointer)
+            return Anchor(runtimeAnchor, perceptionStateExtender.xrResourcesManager)
+        }
+
+        /** Deletes a persisted Anchor denoted by [uuid] from local storage. */
+        @JvmStatic
+        public fun unpersist(session: Session, uuid: UUID) {
+            session.runtime.perceptionManager.unpersistAnchor(uuid)
+        }
+
+        private fun getPerceptionStateExtender(session: Session): PerceptionStateExtender {
+            val perceptionStateExtender: PerceptionStateExtender? =
+                session.stateExtenders.filterIsInstance<PerceptionStateExtender>().first()
+            check(perceptionStateExtender != null) { "PerceptionStateExtender is not available." }
+            return perceptionStateExtender
+        }
+
+        private fun generateCreateResult(
+            runtimeAnchor: RuntimeAnchor,
+            xrResourceManager: XrResourcesManager,
+        ): AnchorCreateResult {
+            val anchor = Anchor(runtimeAnchor, xrResourceManager)
+            xrResourceManager.addUpdatable(anchor)
+            return AnchorCreateSuccess(anchor)
+        }
+    }
+
+    // TODO(b/372049781): This constructor is only used for testing. Remove it once cl/683360061 is
+    // submitted.
+    public constructor(runtimeAnchor: RuntimeAnchor) : this(runtimeAnchor, XrResourcesManager())
+
+    /**
+     * The representation of the current state of an [Anchor].
+     *
+     * @property trackingState the current [TrackingState] of the anchor.
+     * @property pose the location of the anchor in the world coordinate space.
+     */
+    public class State(public val trackingState: TrackingState, public val pose: Pose) {
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is State) return false
+            return pose == other.pose && trackingState == other.trackingState
+        }
+
+        override fun hashCode(): Int {
+            var result = pose.hashCode()
+            result = 31 * result + trackingState.hashCode()
+            return result
+        }
+    }
+
+    private val _state: MutableStateFlow<State> =
+        MutableStateFlow<State>(State(runtimeAnchor.trackingState, runtimeAnchor.pose))
+    /** The current [State] of this anchor. */
+    public val state: StateFlow<State> = _state.asStateFlow()
+
+    private var persistContinuation: Continuation<UUID>? = null
+
+    /**
+     * Stores this anchor in the application's local storage so that it can be shared across
+     * sessions.
+     *
+     * @return the [UUID] that uniquely identifies this anchor.
+     */
+    public suspend fun persist(): UUID {
+        runtimeAnchor.persist()
+        // Suspend the coroutine until the anchor is persisted.
+        return suspendCancellableCoroutine { persistContinuation = it }
+    }
+
+    /** Detaches this anchor. This anchor will no longer be updated or tracked. */
+    public fun detach() {
+        runtimeAnchor.detach()
+        xrResourceManager.removeUpdatable(this)
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Anchor) return false
+        return runtimeAnchor == other.runtimeAnchor
+    }
+
+    override fun hashCode(): Int = runtimeAnchor.hashCode()
+
+    override suspend fun update() {
+        _state.emit(State(runtimeAnchor.trackingState, runtimeAnchor.pose))
+        if (persistContinuation == null) {
+            return
+        }
+        when (runtimeAnchor.persistenceState) {
+            RuntimeAnchor.PersistenceState.Pending -> {
+                // Do nothing while we wait for the anchor to be persisted.
+            }
+            RuntimeAnchor.PersistenceState.Persisted -> {
+                persistContinuation?.resume(runtimeAnchor.uuid!!)
+                persistContinuation = null
+            }
+            RuntimeAnchor.PersistenceState.NotPersisted -> {
+                persistContinuation?.resumeWithException(
+                    RuntimeException("Anchor was not persisted.")
+                )
+                persistContinuation = null
+            }
+        }
+    }
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/AnchorResults.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/AnchorResults.kt
new file mode 100644
index 0000000..2ff9370
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/AnchorResults.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+
+/** Result of a [Anchor.create] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public sealed class AnchorCreateResult
+
+/**
+ * Result of a successful [Anchor.create] call.
+ *
+ * @property anchor the [Anchor] that was created.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AnchorCreateSuccess(public val anchor: Anchor) : AnchorCreateResult()
+
+/**
+ * Result of an unsuccessful [Anchor.create] call. The resources allocated for anchors has been
+ * exhausted.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AnchorCreateResourcesExhausted() : AnchorCreateResult()
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/HitResult.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/HitResult.kt
new file mode 100644
index 0000000..43dd2b8
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/HitResult.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Defines an intersection between a ray and estimated real-world geometry.
+ *
+ * Can be obtained from [Interaction.hitTest].
+ *
+ * @property distance the distance from the camera to the hit location, in meters.
+ * @property hitPose the [Pose] of the intersection between a ray and the [Trackable].
+ * @property trackable the [Trackable] that was hit.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class HitResult
+internal constructor(
+    public val distance: Float,
+    public val hitPose: Pose,
+    public val trackable: Trackable<Trackable.State>,
+) {
+    public fun createAnchor(): Anchor {
+        return trackable.createAnchor(hitPose)
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is HitResult) return false
+
+        if (distance != other.distance) return false
+        if (hitPose != other.hitPose) return false
+        if (trackable != other.trackable) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = distance.hashCode()
+        result = 31 * result + hitPose.hashCode()
+        result = 31 * result + trackable.hashCode()
+        return result
+    }
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Interaction.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Interaction.kt
new file mode 100644
index 0000000..0a656f6
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Interaction.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("Interaction")
+
+package androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.math.Ray
+
+/**
+ * Performs a hit-test using the given [ray].
+ *
+ * A hit-test is a method of calculating the intersection of a ray with objects tracked by the
+ * session. Conducting a hit-test results in a list of hit objects, in other words, a hit-test does
+ * not stop at the first object hit.
+ *
+ * @return A list of [HitResult] objects, sorted by distance from the origin of the ray. The nearest
+ *   hit is at the beginning of the list.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hitTest(session: Session, ray: Ray): List<HitResult> {
+    val perceptionStateExtender =
+        session.stateExtenders.filterIsInstance<PerceptionStateExtender>().first()
+    val perceptionManager = perceptionStateExtender.perceptionManager
+    val trackableMap = perceptionStateExtender.xrResourcesManager.trackablesMap
+    return perceptionManager.hitTest(ray).map {
+        val trackable =
+            requireNotNull(trackableMap[it.trackable]) {
+                "No Active Trackable found for the given hit result."
+            }
+        HitResult(it.distance, it.hitPose, trackable)
+    }
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionState.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionState.kt
new file mode 100644
index 0000000..ae0dc58
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionState.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import kotlin.time.ComparableTimeMark
+
+/**
+ * Represents the state of ARCore for Jetpack XR at an specific point in time.
+ *
+ * Can be obtained from [CoreState.perceptionState].
+ *
+ * @property timeMark the time at which the state was computed.
+ * @property trackables the trackables that are currently being tracked.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PerceptionState
+internal constructor(
+    public val timeMark: ComparableTimeMark,
+    public val trackables: Collection<Trackable<Trackable.State>>,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PerceptionState) return false
+        if (timeMark != other.timeMark) return false
+        if (trackables != other.trackables) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = timeMark.hashCode()
+        result = 31 * result + trackables.hashCode()
+        return result
+    }
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionStateExtender.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionStateExtender.kt
new file mode 100644
index 0000000..58d82a2
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/PerceptionStateExtender.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.CoreState
+import androidx.xr.runtime.StateExtender
+import androidx.xr.runtime.internal.PerceptionManager
+import androidx.xr.runtime.internal.Runtime
+import kotlin.time.ComparableTimeMark
+
+/** [StateExtender] in charge of extending [CoreState] with [PerceptionState]. */
+internal class PerceptionStateExtender : StateExtender {
+
+    internal companion object {
+        internal const val MAX_PERCEPTION_STATE_EXTENSION_SIZE = 100
+
+        internal val perceptionStateMap = mutableMapOf<ComparableTimeMark, PerceptionState>()
+
+        private val timeMarkQueue = ArrayDeque<ComparableTimeMark>()
+    }
+
+    internal lateinit var perceptionManager: PerceptionManager
+
+    internal val xrResourcesManager = XrResourcesManager()
+
+    override fun initialize(runtime: Runtime) {
+        perceptionManager = runtime.perceptionManager
+    }
+
+    override suspend fun extend(coreState: CoreState) {
+        check(this::perceptionManager.isInitialized) {
+            "PerceptionStateExtender is not initialized."
+        }
+
+        xrResourcesManager.syncTrackables(perceptionManager.trackables)
+        xrResourcesManager.update()
+        updatePerceptionStateMap(coreState)
+    }
+
+    internal fun close() {
+        perceptionStateMap.clear()
+        timeMarkQueue.clear()
+        xrResourcesManager.clear()
+    }
+
+    private fun updatePerceptionStateMap(coreState: CoreState) {
+        perceptionStateMap.put(
+            coreState.timeMark,
+            PerceptionState(coreState.timeMark, xrResourcesManager.trackablesMap.values),
+        )
+        timeMarkQueue.add(coreState.timeMark)
+
+        if (timeMarkQueue.size > MAX_PERCEPTION_STATE_EXTENSION_SIZE) {
+            val timeMark = timeMarkQueue.removeFirst()
+            perceptionStateMap.remove(timeMark)
+        }
+    }
+}
+
+/** The state of the perception system. */
+@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public val CoreState.perceptionState: PerceptionState?
+    get() = PerceptionStateExtender.perceptionStateMap[this.timeMark]
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Plane.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Plane.kt
new file mode 100644
index 0000000..5cb27ec
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Plane.kt
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.internal.Plane as RuntimePlane
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector2
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transform
+
+/** Describes the system's current best knowledge of a real-world planar surface. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Plane
+internal constructor(
+    internal val runtimePlane: RuntimePlane,
+    private val xrResourceManager: XrResourcesManager,
+) : Trackable<Plane.State>, Updatable {
+
+    public companion object {
+        /** Emits the planes that are currently being tracked in the [session]. */
+        @JvmStatic
+        public fun subscribe(session: Session): StateFlow<Collection<Plane>> =
+            session.state
+                .transform { state ->
+                    state.perceptionState?.let { perceptionState ->
+                        emit(perceptionState.trackables.filterIsInstance<Plane>())
+                    }
+                }
+                .stateIn(
+                    session.coroutineScope,
+                    SharingStarted.Eagerly,
+                    session.state.value.perceptionState?.trackables?.filterIsInstance<Plane>()
+                        ?: emptyList(),
+                )
+    }
+
+    /**
+     * The representation of the current state of a [Plane].
+     *
+     * @property trackingState whether this plane is being tracked or not.
+     * @property label The [Label] associated with the plane.
+     * @property centerPose The pose of the center of the detected plane.
+     * @property extents The dimensions of the detected plane.
+     * @property subsumedBy If this plane has been subsumed, returns the plane this plane was merged
+     *   into.
+     * @property vertices The 2D vertices of a convex polygon approximating the detected plane.
+     */
+    public class State(
+        public override val trackingState: TrackingState,
+        public val label: Label,
+        public val centerPose: Pose,
+        public val extents: Vector2,
+        public val subsumedBy: Plane?,
+        public val vertices: List<Vector2>,
+    ) : Trackable.State {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is State) return false
+            return trackingState == other.trackingState &&
+                label == other.label &&
+                centerPose == other.centerPose &&
+                extents == other.extents &&
+                subsumedBy == other.subsumedBy &&
+                vertices == other.vertices
+        }
+
+        override fun hashCode(): Int {
+            var result = trackingState.hashCode()
+            result = 31 * result + label.hashCode()
+            result = 31 * result + centerPose.hashCode()
+            result = 31 * result + extents.hashCode()
+            result = 31 * result + subsumedBy.hashCode()
+            result = 31 * result + vertices.hashCode()
+            return result
+        }
+    }
+
+    /** A simple summary of the normal vector of a [Plane]. */
+    public class Type private constructor(private val value: Int) {
+        public companion object {
+            /** A horizontal plane facing upward (e.g. floor or tabletop). */
+            @JvmField public val HorizontalUpwardFacing: Type = Type(0)
+
+            /** A horizontal plane facing downward (e.g. a ceiling). */
+            @JvmField public val HorizontalDownwardFacing: Type = Type(1)
+
+            /** A vertical plane (e.g. a wall). */
+            @JvmField public val Vertical: Type = Type(2)
+        }
+
+        public override fun toString(): String =
+            when (this) {
+                HorizontalUpwardFacing -> "HorizontalUpwardFacing"
+                HorizontalDownwardFacing -> "HorizontalDownwardFacing"
+                Vertical -> "Vertical"
+                else -> "Unknown"
+            }
+    }
+
+    /** A semantic description of a [Plane]. */
+    public class Label private constructor(private val value: Int) {
+        public companion object {
+            /** The plane represents an unknown type. */
+            @JvmField public val Unknown: Label = Label(0)
+
+            /** The plane represents a wall. */
+            @JvmField public val Wall: Label = Label(1)
+
+            /** The plane represents a floor. */
+            @JvmField public val Floor: Label = Label(2)
+
+            /** The plane represents a ceiling. */
+            @JvmField public val Ceiling: Label = Label(3)
+
+            /** The plane represents a table. */
+            @JvmField public val Table: Label = Label(4)
+        }
+
+        public override fun toString(): String =
+            when (this) {
+                Wall -> "Wall"
+                Floor -> "Floor"
+                Ceiling -> "Ceiling"
+                Table -> "Table"
+                else -> "Unknown"
+            }
+    }
+
+    private val _state =
+        MutableStateFlow(
+            State(
+                runtimePlane.trackingState,
+                labelFromRuntimeType(),
+                runtimePlane.centerPose,
+                runtimePlane.extents,
+                subsumedByFromRuntimePlane(),
+                runtimePlane.vertices,
+            )
+        )
+    /** The current state of the [Plane]. */
+    public override val state: StateFlow<Plane.State> = _state.asStateFlow()
+
+    /** The [Type] of the [Plane]. */
+    public val type: Type
+        get() = typeFromRuntimeType()
+
+    override fun createAnchor(pose: Pose): Anchor {
+        val runtimeAnchor = runtimePlane.createAnchor(pose)
+        val anchor = Anchor(runtimeAnchor, xrResourceManager)
+        xrResourceManager.addUpdatable(anchor)
+        return anchor
+    }
+
+    override suspend fun update() {
+        _state.emit(
+            State(
+                trackingState = runtimePlane.trackingState,
+                label = labelFromRuntimeType(),
+                centerPose = runtimePlane.centerPose,
+                extents = runtimePlane.extents,
+                subsumedBy = subsumedByFromRuntimePlane(),
+                vertices = runtimePlane.vertices,
+            )
+        )
+    }
+
+    private fun typeFromRuntimeType(): Type =
+        when (runtimePlane.type) {
+            RuntimePlane.Type.HorizontalUpwardFacing -> Type.HorizontalUpwardFacing
+            RuntimePlane.Type.HorizontalDownwardFacing -> Type.HorizontalDownwardFacing
+            RuntimePlane.Type.Vertical -> Type.Vertical
+            else -> Type.HorizontalUpwardFacing
+        }
+
+    private fun labelFromRuntimeType(): Label =
+        when (runtimePlane.label) {
+            RuntimePlane.Label.Unknown -> Label.Unknown
+            RuntimePlane.Label.Wall -> Label.Wall
+            RuntimePlane.Label.Floor -> Label.Floor
+            RuntimePlane.Label.Ceiling -> Label.Ceiling
+            RuntimePlane.Label.Table -> Label.Table
+            else -> Label.Unknown
+        }
+
+    private fun subsumedByFromRuntimePlane(): Plane? =
+        runtimePlane.subsumedBy?.let { xrResourceManager.trackablesMap[it] as Plane? }
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Trackable.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Trackable.kt
new file mode 100644
index 0000000..3cc159d
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Trackable.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+import kotlinx.coroutines.flow.StateFlow
+
+/** An object that ARCore for Jetpack XR can track and that [Anchors] can be attached to. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Trackable<out State> {
+
+    /** The subset of data that is common to the state of all [Trackable] instances. */
+    public interface State {
+        /** Whether this trackable is being tracked or not. */
+        public val trackingState: TrackingState
+    }
+
+    /** Emits the current state of this trackable. */
+    public val state: StateFlow<Trackable.State>
+
+    /**
+     * Creates an [Anchor] that is attached to this trackable, using the given initial [pose] in the
+     * world coordinate space.
+     */
+    public fun createAnchor(pose: Pose): Anchor
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/TrackingState.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/TrackingState.kt
new file mode 100644
index 0000000..9bd7cde
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/TrackingState.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+public typealias TrackingState = androidx.xr.runtime.internal.TrackingState
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Updatable.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Updatable.kt
new file mode 100644
index 0000000..8db387a
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/Updatable.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+/** An interface for objects that need to be updated every frame. */
+internal interface Updatable {
+
+    /** Updates the state of the [Updatable]. */
+    suspend fun update()
+}
diff --git a/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/XrResourcesManager.kt b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/XrResourcesManager.kt
new file mode 100644
index 0000000..1eabee9
--- /dev/null
+++ b/xr/arcore/arcore/src/main/kotlin/androidx/xr/arcore/XrResourcesManager.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import android.annotation.SuppressLint
+import androidx.xr.runtime.internal.Plane as RuntimePlane
+import androidx.xr.runtime.internal.Trackable as RuntimeTrackable
+import java.util.concurrent.CopyOnWriteArrayList
+import kotlinx.coroutines.flow.update
+
+/** Manages all XR resources that are used by the ARCore for XR API. */
+internal class XrResourcesManager {
+
+    /** List of [Updatable]s that are updated every frame. */
+    private val _updatables = CopyOnWriteArrayList<Updatable>()
+    val updatables: List<Updatable> = _updatables
+
+    /** Map of runtime trackable pointer to [Trackable]. */
+    @SuppressLint("BanConcurrentHashMap")
+    private val _trackablesMap =
+        java.util.concurrent.ConcurrentHashMap<RuntimeTrackable, Trackable<Trackable.State>>()
+    val trackablesMap: Map<RuntimeTrackable, Trackable<Trackable.State>> = _trackablesMap
+
+    internal fun addUpdatable(updatable: Updatable) {
+        _updatables.add(updatable)
+    }
+
+    internal fun removeUpdatable(updatable: Updatable) {
+        _updatables.remove(updatable)
+    }
+
+    internal suspend fun update() {
+        for (updatable in updatables) {
+            updatable.update()
+        }
+    }
+
+    internal fun syncTrackables(runtimeTrackables: Collection<RuntimeTrackable>) {
+        val toRemoveTrackables = _trackablesMap.keys - runtimeTrackables
+        val toAddTrackables = runtimeTrackables - _trackablesMap.keys
+
+        for (runtimeTrackable in toRemoveTrackables) {
+            removeUpdatable(_trackablesMap[runtimeTrackable]!! as Updatable)
+            _trackablesMap.remove(runtimeTrackable)
+        }
+
+        for (runtimeTrackable in toAddTrackables) {
+            val trackable = createTrackable(runtimeTrackable)
+            _trackablesMap[runtimeTrackable] = trackable
+            addUpdatable(trackable as Updatable)
+        }
+    }
+
+    internal fun clear() {
+        _updatables.clear()
+        _trackablesMap.clear()
+    }
+
+    private fun createTrackable(runtimeTrackable: RuntimeTrackable): Trackable<Trackable.State> {
+        if (_trackablesMap.containsKey(runtimeTrackable)) {
+            return _trackablesMap[runtimeTrackable]!!
+        }
+
+        val trackable =
+            when (runtimeTrackable) {
+                is RuntimePlane -> Plane(runtimeTrackable, this)
+                else ->
+                    throw IllegalArgumentException(
+                        "Unsupported trackable type: ${runtimeTrackable.javaClass}"
+                    )
+            }
+        _trackablesMap[runtimeTrackable] = trackable
+        return trackable
+    }
+}
diff --git a/xr/arcore/arcore/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender b/xr/arcore/arcore/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender
new file mode 100644
index 0000000..47bee1d
--- /dev/null
+++ b/xr/arcore/arcore/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender
@@ -0,0 +1,15 @@
+# Copyright 2024 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.
+#
+androidx.xr.arcore.PerceptionStateExtender
\ No newline at end of file
diff --git a/xr/arcore/arcore/src/test/AndroidManifest.xml b/xr/arcore/arcore/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..666fbed
--- /dev/null
+++ b/xr/arcore/arcore/src/test/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <application>
+        <activity android:name="android.app.Activity" />
+    </application>
+</manifest>
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/AnchorTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/AnchorTest.kt
new file mode 100644
index 0000000..ab6b085
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/AnchorTest.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import android.app.Activity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.GrantPermissionRule
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.SessionCreateSuccess
+import androidx.xr.runtime.internal.Anchor as RuntimeAnchor
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.runtime.testing.FakePerceptionManager
+import androidx.xr.runtime.testing.FakeRuntimeAnchor
+import androidx.xr.runtime.testing.FakeRuntimePlane
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AnchorTest {
+
+    private lateinit var xrResourcesManager: XrResourcesManager
+
+    @get:Rule
+    val grantPermissionRule = GrantPermissionRule.grant("android.permission.SCENE_UNDERSTANDING")
+
+    @Before
+    fun setUp() {
+        xrResourcesManager = XrResourcesManager()
+    }
+
+    @After
+    fun tearDown() {
+        xrResourcesManager.clear()
+    }
+
+    @Test
+    fun detach_removeAnchorFromActiveAnchorManager() {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        xrResourcesManager.addUpdatable(underTest)
+        check(xrResourcesManager.updatables.contains(underTest))
+        check(xrResourcesManager.updatables.size == 1)
+
+        underTest.detach()
+
+        assertThat(xrResourcesManager.updatables).isEmpty()
+    }
+
+    @Test
+    fun update_trackingStateMatchesRuntimeTrackingState() = runBlocking {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        runtimeAnchor.trackingState = TrackingState.Paused
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        check(underTest.state.value.trackingState.equals(TrackingState.Paused))
+        runtimeAnchor.trackingState = TrackingState.Tracking
+
+        underTest.update()
+
+        assertThat(underTest.state.value.trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun update_poseMatchesRuntimePose() = runBlocking {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        check(
+            underTest.state.value.pose.equals(
+                Pose(Vector3(0f, 0f, 0f), Quaternion(0f, 0f, 0f, 1.0f))
+            )
+        )
+        val newPose = Pose(Vector3(1.0f, 2.0f, 3.0f), Quaternion(1.0f, 2.0f, 3.0f, 4.0f))
+        runtimeAnchor.pose = newPose
+
+        underTest.update()
+
+        assertThat(underTest.state.value.pose).isEqualTo(newPose)
+    }
+
+    @Test
+    fun persist_runtimeAnchorIsPersisted() = runTest {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        check(runtimeAnchor.persistenceState == RuntimeAnchor.PersistenceState.NotPersisted)
+
+        var uuid: UUID? = null
+        val persistJob = launch { uuid = underTest.persist() }
+        val updateJob = launch { underTest.update() }
+        updateJob.join()
+        persistJob.join()
+
+        assertThat(uuid).isNotNull()
+        assertThat(runtimeAnchor.persistenceState)
+            .isEqualTo(RuntimeAnchor.PersistenceState.Persisted)
+    }
+
+    @Test
+    fun getPersistedAnchorUuids_previouslyPersistedAnchor_returnsPersistedAnchorUuid() = runTest {
+        val session = createTestSession()
+        val runtimeAnchor =
+            FakeRuntimeAnchor(Pose(), session.runtime.perceptionManager as FakePerceptionManager)
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        var uuid: UUID? = null
+        val persistJob = launch { uuid = underTest.persist() }
+        val updateJob = launch { underTest.update() }
+        updateJob.join()
+        persistJob.join()
+
+        assertThat(Anchor.getPersistedAnchorUuids(session)).containsExactly(uuid)
+    }
+
+    @Test
+    @Ignore("Flaky test, see b/380269912")
+    fun getPersistedAnchorUuids_noPreviouslyPersistedAnchors_returnsEmptyList() = runTest {
+        val session = createTestSession()
+
+        assertThat(Anchor.getPersistedAnchorUuids(session)).isEmpty()
+    }
+
+    @Test
+    fun load_previouslyPersistedAnchor_returnsAnchorCreateSuccess() = runTest {
+        val session = createTestSession()
+        val runtimeAnchor =
+            FakeRuntimeAnchor(Pose(), session.runtime.perceptionManager as FakePerceptionManager)
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        var uuid: UUID? = null
+        val persistJob = launch { uuid = underTest.persist() }
+        val updateJob = launch { underTest.update() }
+        updateJob.join()
+        persistJob.join()
+
+        assertThat(Anchor.load(session, uuid!!)).isInstanceOf(AnchorCreateSuccess::class.java)
+    }
+
+    @Test
+    fun loadFromNativePointer_returnsAnchorCreateSuccess() = runTest {
+        val session = createTestSession()
+
+        assertThat(Anchor.loadFromNativePointer(session, 123L)).isNotNull()
+    }
+
+    @Test
+    fun unpersist_removesAnchorFromStorage() = runTest {
+        val session = createTestSession()
+        val runtimeAnchor =
+            FakeRuntimeAnchor(Pose(), session.runtime.perceptionManager as FakePerceptionManager)
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+        var uuid: UUID? = null
+        val persistJob = launch { uuid = underTest.persist() }
+        val updateJob = launch { underTest.update() }
+        updateJob.join()
+        persistJob.join()
+
+        Anchor.unpersist(session, uuid!!)
+
+        assertThat(Anchor.getPersistedAnchorUuids(session)).doesNotContain(uuid)
+    }
+
+    @Test
+    fun detach_removesRuntimeAnchor() {
+        val runtimeAnchor = FakeRuntimePlane().createAnchor(Pose()) as FakeRuntimeAnchor
+        check(runtimeAnchor.isAttached)
+        val underTest = Anchor(runtimeAnchor, xrResourcesManager)
+
+        underTest.detach()
+
+        assertThat(runtimeAnchor.isAttached).isFalse()
+    }
+
+    @Test
+    fun equals_sameObject_returnsTrue() {
+        val underTest = Anchor(FakeRuntimeAnchor(Pose()), xrResourcesManager)
+
+        assertThat(underTest.equals(underTest)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsSameValues_returnsTrue() {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val underTest1 = Anchor(runtimeAnchor, xrResourcesManager)
+        val underTest2 = Anchor(runtimeAnchor, xrResourcesManager)
+
+        assertThat(underTest1.equals(underTest2)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsDifferentValues_returnsFalse() {
+        val underTest1 =
+            Anchor(FakeRuntimeAnchor(Pose(Vector3.Up, Quaternion.Identity)), xrResourcesManager)
+        val underTest2 =
+            Anchor(FakeRuntimeAnchor(Pose(Vector3.Down, Quaternion.Identity)), xrResourcesManager)
+
+        assertThat(underTest1.equals(underTest2)).isFalse()
+    }
+
+    @Test
+    fun hashCode_differentObjectsSameValues_returnsSameHashCode() {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val underTest1 = Anchor(runtimeAnchor, xrResourcesManager)
+        val underTest2 = Anchor(runtimeAnchor, xrResourcesManager)
+
+        assertThat(underTest1.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCode_differentObjectsDifferentValues_returnsDifferentHashCodes() {
+        val underTest1 =
+            Anchor(FakeRuntimeAnchor(Pose(Vector3.Up, Quaternion.Identity)), xrResourcesManager)
+        val underTest2 =
+            Anchor(FakeRuntimeAnchor(Pose(Vector3.Down, Quaternion.Identity)), xrResourcesManager)
+
+        assertThat(underTest1.hashCode()).isNotEqualTo(underTest2.hashCode())
+    }
+
+    private fun createTestSession(): Session {
+        var session: Session? = null
+        ActivityScenario.launch(Activity::class.java).use {
+            it.onActivity { activity ->
+                session =
+                    (Session.create(activity, StandardTestDispatcher()) as SessionCreateSuccess)
+                        .session
+            }
+        }
+        return checkNotNull(session) { "Session must not be null." }
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/HitResultTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/HitResultTest.kt
new file mode 100644
index 0000000..93be668
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/HitResultTest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.math.Pose
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HitResultTest {
+
+    class TestTrackable : Trackable<Trackable.State> {
+        override fun createAnchor(pose: Pose): Anchor {
+            throw NotImplementedError("Not implemented")
+        }
+
+        override val state: StateFlow<Trackable.State> =
+            MutableStateFlow(
+                object : Trackable.State {
+                    override val trackingState = TrackingState.Stopped
+                }
+            )
+    }
+
+    @Test
+    fun equals_sameObject_returnsTrue() {
+        val underTest = HitResult(1.0f, Pose(), TestTrackable())
+
+        assertThat(underTest.equals(underTest)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsSameValues_returnsTrue() {
+        val distance = 1.0f
+        val pose = Pose()
+        val trackable = TestTrackable()
+        val underTest1 = HitResult(distance, pose, trackable)
+        val underTest2 = HitResult(distance, pose, trackable)
+
+        assertThat(underTest1.equals(underTest2)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsDifferentValues_returnsFalse() {
+        val underTest1 = HitResult(1.0f, Pose(), TestTrackable())
+        val underTest2 = HitResult(2.0f, Pose(), TestTrackable())
+
+        assertThat(underTest1.equals(underTest2)).isFalse()
+    }
+
+    @Test
+    fun hashCode_differentObjectsSameValues_returnsSameHashCode() {
+        val distance = 1.0f
+        val pose = Pose()
+        val trackable = TestTrackable()
+        val underTest1 = HitResult(distance, pose, trackable)
+        val underTest2 = HitResult(distance, pose, trackable)
+
+        assertThat(underTest1.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCode_differentObjectsDifferentValues_returnsDifferentHashCodes() {
+        val underTest1 = HitResult(1.0f, Pose(), TestTrackable())
+        val underTest2 = HitResult(2.0f, Pose(), TestTrackable())
+
+        assertThat(underTest1.hashCode()).isNotEqualTo(underTest2.hashCode())
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/InteractionTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/InteractionTest.kt
new file mode 100644
index 0000000..8a91c6c
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/InteractionTest.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import android.app.Activity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.GrantPermissionRule
+import androidx.xr.runtime.CoreState
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.SessionCreateSuccess
+import androidx.xr.runtime.internal.HitResult as RuntimeHitResult
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Ray
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.runtime.testing.FakePerceptionManager
+import androidx.xr.runtime.testing.FakeRuntime
+import androidx.xr.runtime.testing.FakeRuntimePlane
+import com.google.common.truth.Truth.assertThat
+import kotlin.time.TestTimeSource
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class InteractionTest {
+
+    private lateinit var session: Session
+    private lateinit var activity: Activity
+    private lateinit var timeSource: TestTimeSource
+    private lateinit var perceptionStateExtender: PerceptionStateExtender
+    private lateinit var perceptionManager: FakePerceptionManager
+
+    @get:Rule
+    val grantPermissionRule = GrantPermissionRule.grant("android.permission.SCENE_UNDERSTANDING")
+
+    @Before
+    fun setUp() {
+        session = createTestSession()
+        timeSource = (session.runtime as FakeRuntime).lifecycleManager.timeSource
+        perceptionStateExtender =
+            session.stateExtenders.filterIsInstance<PerceptionStateExtender>().first()
+        perceptionManager = perceptionStateExtender.perceptionManager as FakePerceptionManager
+    }
+
+    @Test
+    fun hitTest_successWithOneHitResult() = runTest {
+        val runtimePlane = FakeRuntimePlane()
+        perceptionManager.addTrackable(runtimePlane)
+        // Mock the behavior of session update.
+        val timeMark = timeSource.markNow()
+        val state = CoreState(timeMark)
+        perceptionStateExtender.extend(state)
+        check(state.perceptionState?.trackables?.size == 1)
+        val expectedTrackable = state.perceptionState?.trackables?.first()
+        val runtimeHitResult: RuntimeHitResult =
+            RuntimeHitResult(
+                distance = 1f,
+                hitPose = Pose(Vector3(1f, 2f, 3f), Quaternion(4f, 5f, 6f, 7f)),
+                trackable = runtimePlane,
+            )
+        perceptionManager.addHitResult(runtimeHitResult)
+
+        val hitResults = hitTest(session, Ray(Vector3(0f, 0f, 0f), Vector3(0f, 0f, 1f)))
+
+        assertThat(hitResults.size).isEqualTo(1)
+        assertThat(hitResults[0].distance).isEqualTo(runtimeHitResult.distance)
+        assertThat(hitResults[0].hitPose).isEqualTo(runtimeHitResult.hitPose)
+        assertThat(hitResults[0].trackable).isEqualTo(expectedTrackable)
+    }
+
+    private fun createTestSession(): Session {
+        var session: Session? = null
+        ActivityScenario.launch(Activity::class.java).use {
+            it.onActivity { activity ->
+                session =
+                    (Session.create(activity, StandardTestDispatcher()) as SessionCreateSuccess)
+                        .session
+            }
+        }
+        return checkNotNull(session) { "Session must not be null." }
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PerceptionStateExtenderTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PerceptionStateExtenderTest.kt
new file mode 100644
index 0000000..4beb754
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PerceptionStateExtenderTest.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import android.app.Activity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.CoreState
+import androidx.xr.runtime.internal.Trackable as RuntimeTrackable
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.testing.FakeRuntime
+import androidx.xr.runtime.testing.FakeRuntimeFactory
+import androidx.xr.runtime.testing.FakeRuntimePlane
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.TestTimeSource
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PerceptionStateExtenderTest {
+
+    private lateinit var fakeRuntime: FakeRuntime
+    private lateinit var timeSource: TestTimeSource
+    private lateinit var underTest: PerceptionStateExtender
+
+    @Before
+    fun setUp() {
+        fakeRuntime = FakeRuntimeFactory().createRuntime(Activity())
+        timeSource = fakeRuntime.lifecycleManager.timeSource
+        underTest = PerceptionStateExtender()
+    }
+
+    @After
+    fun tearDown() {
+        underTest.close()
+    }
+
+    @Test
+    fun extend_notInitialized_throwsIllegalStateException(): Unit = runBlocking {
+        val coreState = CoreState(timeSource.markNow())
+
+        assertFailsWith<IllegalStateException> { underTest.extend(coreState) }
+    }
+
+    @Test
+    fun extend_withOneState_addsAllTrackablesToTheCollection(): Unit = runBlocking {
+        // arrange
+        underTest.initialize(fakeRuntime)
+
+        val timeMark = timeSource.markNow()
+        val coreState = CoreState(timeMark)
+        val runtimeTrackable: RuntimeTrackable = FakeRuntimePlane()
+        fakeRuntime.perceptionManager.addTrackable(runtimeTrackable)
+
+        // act
+        underTest.extend(coreState)
+
+        // assert
+        val perceptionState = coreState.perceptionState!!
+        assertThat(perceptionState.trackables).hasSize(1)
+        assertThat(convertTrackable(perceptionState.trackables.last())).isEqualTo(runtimeTrackable)
+    }
+
+    @Test
+    fun extend_withTwoStates_updatesAllTrackablesInTheirCollection(): Unit = runBlocking {
+        // arrange
+        underTest.initialize(fakeRuntime)
+        fakeRuntime.perceptionManager.addTrackable(FakeRuntimePlane())
+        underTest.extend(CoreState(timeSource.markNow()))
+
+        timeSource += 10.milliseconds
+        val timeMark = timeSource.markNow()
+        fakeRuntime.perceptionManager.trackables.clear()
+        val runtimeTrackable = FakeRuntimePlane()
+        fakeRuntime.perceptionManager.addTrackable(runtimeTrackable)
+        val coreState = CoreState(timeMark)
+
+        // act
+        underTest.extend(coreState)
+
+        // assert
+        val perceptionState = coreState.perceptionState!!
+        assertThat(perceptionState.trackables).hasSize(1)
+        assertThat(convertTrackable(perceptionState.trackables.last())).isEqualTo(runtimeTrackable)
+    }
+
+    @Test
+    fun extend_withTwoStates_trackableStatusUpdated(): Unit = runBlocking {
+        // arrange
+        underTest.initialize(fakeRuntime)
+        val runtimeTrackable = FakeRuntimePlane()
+        fakeRuntime.perceptionManager.addTrackable(runtimeTrackable)
+        val coreState = CoreState(timeSource.markNow())
+        underTest.extend(coreState)
+        check(
+            coreState.perceptionState!!.trackables.last().state.value.trackingState ==
+                TrackingState.Tracking
+        )
+
+        // act
+        timeSource += 10.milliseconds
+        runtimeTrackable.trackingState = TrackingState.Stopped
+        val coreState2 = CoreState(timeSource.markNow())
+        underTest.extend(coreState2)
+
+        // assert
+        assertThat(coreState2.perceptionState!!.trackables.last().state.value.trackingState)
+            .isEqualTo(TrackingState.Stopped)
+    }
+
+    @Test
+    fun extend_perceptionStateMapSizeExceedsMax(): Unit = runBlocking {
+        // arrange
+        underTest.initialize(fakeRuntime)
+        val timeMark = timeSource.markNow()
+        val coreState = CoreState(timeMark)
+
+        // act
+        underTest.extend(coreState)
+        // make sure the perception state is added to the map at the beginning
+        check(coreState.perceptionState != null)
+
+        for (i in 1..PerceptionStateExtender.MAX_PERCEPTION_STATE_EXTENSION_SIZE) {
+            timeSource += 10.milliseconds
+            underTest.extend(CoreState(timeSource.markNow()))
+        }
+
+        // assert
+        assertThat(coreState.perceptionState).isNull()
+    }
+
+    @Test
+    fun close_cleanUpData(): Unit = runBlocking {
+        // arrange
+        underTest.initialize(fakeRuntime)
+        val timeMark = timeSource.markNow()
+        val coreState = CoreState(timeMark)
+        underTest.extend(coreState)
+        // make sure the perception state is added to the map at the beginning
+        check(coreState.perceptionState != null)
+
+        // act
+        underTest.close()
+
+        // assert
+        assertThat(coreState.perceptionState).isNull()
+    }
+
+    private fun convertTrackable(trackable: Trackable<Trackable.State>): RuntimeTrackable {
+        return when (trackable) {
+            is Plane -> trackable.runtimePlane
+            else ->
+                throw IllegalArgumentException("Unsupported trackable type: ${trackable.javaClass}")
+        }
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PlaneTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PlaneTest.kt
new file mode 100644
index 0000000..cf0a7bc
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/PlaneTest.kt
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import android.app.Activity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.GrantPermissionRule
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.SessionCreateSuccess
+import androidx.xr.runtime.internal.Plane as RuntimePlane
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector2
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.runtime.testing.FakePerceptionManager
+import androidx.xr.runtime.testing.FakeRuntimePlane
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlaneTest {
+    private lateinit var xrResourcesManager: XrResourcesManager
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testScope: TestScope
+
+    @get:Rule
+    val grantPermissionRule = GrantPermissionRule.grant("android.permission.SCENE_UNDERSTANDING")
+
+    @Before
+    fun setUp() {
+        xrResourcesManager = XrResourcesManager()
+        testDispatcher = StandardTestDispatcher()
+        testScope = TestScope(testDispatcher)
+    }
+
+    @After
+    fun tearDown() {
+        xrResourcesManager.clear()
+    }
+
+    @Test
+    fun constructor_convertsRuntimePlaneType() {
+        val plane1 =
+            Plane(
+                FakeRuntimePlane(type = RuntimePlane.Type.HorizontalUpwardFacing),
+                xrResourcesManager
+            )
+        val plane2 =
+            Plane(
+                FakeRuntimePlane(type = RuntimePlane.Type.HorizontalDownwardFacing),
+                xrResourcesManager
+            )
+        val plane3 = Plane(FakeRuntimePlane(type = RuntimePlane.Type.Vertical), xrResourcesManager)
+
+        assertThat(plane1.type).isEqualTo(Plane.Type.HorizontalUpwardFacing)
+        assertThat(plane2.type).isEqualTo(Plane.Type.HorizontalDownwardFacing)
+        assertThat(plane3.type).isEqualTo(Plane.Type.Vertical)
+    }
+
+    @Test
+    fun constructor_convertsRuntimePlaneLabel() {
+        val plane1 = Plane(FakeRuntimePlane(label = RuntimePlane.Label.Unknown), xrResourcesManager)
+        val plane2 = Plane(FakeRuntimePlane(label = RuntimePlane.Label.Wall), xrResourcesManager)
+        val plane3 = Plane(FakeRuntimePlane(label = RuntimePlane.Label.Floor), xrResourcesManager)
+        val plane4 = Plane(FakeRuntimePlane(label = RuntimePlane.Label.Ceiling), xrResourcesManager)
+        val plane5 = Plane(FakeRuntimePlane(label = RuntimePlane.Label.Table), xrResourcesManager)
+
+        assertThat(plane1.state.value.label).isEqualTo(Plane.Label.Unknown)
+        assertThat(plane2.state.value.label).isEqualTo(Plane.Label.Wall)
+        assertThat(plane3.state.value.label).isEqualTo(Plane.Label.Floor)
+        assertThat(plane4.state.value.label).isEqualTo(Plane.Label.Ceiling)
+        assertThat(plane5.state.value.label).isEqualTo(Plane.Label.Table)
+    }
+
+    @Test
+    fun subscribe_collectReturnsPlane() =
+        runTest(testDispatcher) {
+            val session = createTestSession(testDispatcher)
+            val perceptionManager = session.runtime.perceptionManager as FakePerceptionManager
+            val runtimePlane = FakeRuntimePlane()
+            perceptionManager.addTrackable(runtimePlane)
+            awaitNewCoreState(session, testScope)
+
+            var underTest = emptyList<Plane>()
+            testScope.backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
+                underTest = Plane.subscribe(session).first().toList()
+            }
+
+            assertThat(underTest.size).isEqualTo(1)
+            assertThat(underTest.first().runtimePlane).isEqualTo(runtimePlane)
+        }
+
+    @Test
+    fun createAnchor_usesGivenPose() {
+        val runtimePlane = FakeRuntimePlane()
+        xrResourcesManager.syncTrackables(listOf(runtimePlane))
+        val underTest = xrResourcesManager.trackablesMap.values.first() as Plane
+        val pose = Pose(Vector3(1.0f, 2.0f, 3.0f), Quaternion(1.0f, 2.0f, 3.0f, 4.0f))
+
+        val anchor = underTest.createAnchor(pose)
+
+        assertThat(anchor.state.value.pose).isEqualTo(pose)
+    }
+
+    @Test
+    fun update_trackingStateMatchesRuntime() = runBlocking {
+        // arrange
+        val runtimePlane = FakeRuntimePlane()
+        runtimePlane.trackingState = TrackingState.Stopped
+        xrResourcesManager.syncTrackables(listOf(runtimePlane))
+        val underTest = xrResourcesManager.trackablesMap.values.first() as Plane
+        check(underTest.state.value.trackingState == TrackingState.Stopped)
+
+        // act
+        runtimePlane.trackingState = TrackingState.Tracking
+        underTest.update()
+
+        // assert
+        assertThat(underTest.state.value.trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun update_centerPoseMatchesRuntime() = runBlocking {
+        // arrange
+        val runtimePlane = FakeRuntimePlane()
+        xrResourcesManager.syncTrackables(listOf(runtimePlane))
+        val underTest = xrResourcesManager.trackablesMap.values.first() as Plane
+        check(
+            underTest.state.value.centerPose.equals(
+                Pose(Vector3(0f, 0f, 0f), Quaternion(0f, 0f, 0f, 1.0f))
+            )
+        )
+
+        // act
+        val newPose = Pose(Vector3(1.0f, 2.0f, 3.0f), Quaternion(1.0f, 2.0f, 3.0f, 4.0f))
+        runtimePlane.centerPose = newPose
+        underTest.update()
+
+        // assert
+        assertThat(underTest.state.value.centerPose).isEqualTo(newPose)
+    }
+
+    @Test
+    fun update_extentsMatchesRuntime() = runBlocking {
+        // arrange
+        val runtimePlane = FakeRuntimePlane()
+        val extents = Vector2(1.0f, 2.0f)
+        runtimePlane.extents = extents
+        xrResourcesManager.syncTrackables(listOf(runtimePlane))
+        val underTest = xrResourcesManager.trackablesMap.values.first() as Plane
+        check(underTest.state.value.extents == extents)
+
+        // act
+        val newExtents = Vector2(3.0f, 4.0f)
+        runtimePlane.extents = newExtents
+        underTest.update()
+
+        // assert
+        assertThat(underTest.state.value.extents).isEqualTo(newExtents)
+    }
+
+    @Test
+    fun update_verticesMatchesRuntime() = runBlocking {
+        // arrange
+        val runtimePlane = FakeRuntimePlane()
+        val vertices = listOf(Vector2(1.0f, 2.0f), Vector2(3.0f, 4.0f))
+        runtimePlane.vertices = vertices
+        xrResourcesManager.syncTrackables(listOf(runtimePlane))
+        val underTest = xrResourcesManager.trackablesMap.values.first() as Plane
+        check(underTest.state.value.vertices == vertices)
+
+        // act
+        val newVertices = listOf(Vector2(3.0f, 4.0f), Vector2(5.0f, 6.0f))
+        runtimePlane.vertices = newVertices
+        underTest.update()
+
+        // assert
+        assertThat(underTest.state.value.vertices).isEqualTo(newVertices)
+    }
+
+    @Test
+    fun update_subsumedByMatchesRuntime() = runBlocking {
+        // arrange
+        val runtimePlane = FakeRuntimePlane()
+        val subsumedByPlane = FakeRuntimePlane()
+        xrResourcesManager.syncTrackables(listOf(runtimePlane, subsumedByPlane))
+        xrResourcesManager.update()
+        val underTest = xrResourcesManager.trackablesMap[runtimePlane] as Plane
+        check(underTest.state.value.subsumedBy == null)
+
+        // act
+        runtimePlane.subsumedBy = subsumedByPlane
+        xrResourcesManager.update()
+
+        // assert
+        assertThat(underTest.state.value.subsumedBy).isNotNull()
+        assertThat(underTest.state.value.subsumedBy!!.runtimePlane).isEqualTo(subsumedByPlane)
+    }
+
+    @Test
+    fun labelToString_returnsCorrectString() {
+        assertThat(Plane.Label.Wall.toString()).isEqualTo("Wall")
+        assertThat(Plane.Label.Floor.toString()).isEqualTo("Floor")
+        assertThat(Plane.Label.Ceiling.toString()).isEqualTo("Ceiling")
+        assertThat(Plane.Label.Table.toString()).isEqualTo("Table")
+        assertThat(Plane.Label.Unknown.toString()).isEqualTo("Unknown")
+    }
+
+    @Test
+    fun typeToString_returnsCorrectString() {
+        assertThat(Plane.Type.HorizontalUpwardFacing.toString()).isEqualTo("HorizontalUpwardFacing")
+        assertThat(Plane.Type.HorizontalDownwardFacing.toString())
+            .isEqualTo("HorizontalDownwardFacing")
+        assertThat(Plane.Type.Vertical.toString()).isEqualTo("Vertical")
+    }
+
+    private fun createTestSession(
+        coroutineDispatcher: CoroutineDispatcher = StandardTestDispatcher()
+    ): Session {
+        var session: Session? = null
+        ActivityScenario.launch(Activity::class.java).use {
+            it.onActivity { activity ->
+                session =
+                    (Session.create(activity, coroutineDispatcher) as SessionCreateSuccess).session
+            }
+        }
+        return checkNotNull(session) { "Session must not be null." }
+    }
+
+    /** Resumes and pauses the session just enough to emit a new CoreState. */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private suspend fun awaitNewCoreState(session: Session, testScope: TestScope) {
+        session.resume()
+        testScope.advanceUntilIdle()
+        session.pause()
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/TrackingStateTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/TrackingStateTest.kt
new file mode 100644
index 0000000..63ad6d1
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/TrackingStateTest.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TrackingStateTest {
+
+    @Test
+    fun toString_returnsCorrectString() {
+        assertThat(TrackingState.Tracking.toString()).isEqualTo("Tracking")
+        assertThat(TrackingState.Paused.toString()).isEqualTo("Paused")
+        assertThat(TrackingState.Stopped.toString()).isEqualTo("Stopped")
+    }
+}
diff --git a/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/XrResourcesManagerTest.kt b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/XrResourcesManagerTest.kt
new file mode 100644
index 0000000..d2560e6
--- /dev/null
+++ b/xr/arcore/arcore/src/test/kotlin/androidx/xr/arcore/XrResourcesManagerTest.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2024 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 androidx.xr.arcore
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.testing.FakeRuntimeAnchor
+import androidx.xr.runtime.testing.FakeRuntimePlane
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class XrResourcesManagerTest {
+
+    private lateinit var underTest: XrResourcesManager
+
+    @Before
+    fun setUp() {
+        underTest = XrResourcesManager()
+    }
+
+    @After
+    fun tearDown() {
+        underTest.clear()
+    }
+
+    @Test
+    fun addUpdatable_addsUpdatable() {
+        val anchor = Anchor(FakeRuntimeAnchor(Pose()), underTest)
+        check(underTest.updatables.isEmpty())
+
+        underTest.addUpdatable(anchor)
+
+        assertThat(underTest.updatables).containsExactly(anchor)
+    }
+
+    @Test
+    fun removeUpdatable_removesUpdatable() {
+        val anchor = Anchor(FakeRuntimeAnchor(Pose()), underTest)
+        underTest.addUpdatable(anchor)
+        check(underTest.updatables.contains(anchor))
+        check(underTest.updatables.size == 1)
+
+        underTest.removeUpdatable(anchor)
+
+        assertThat(underTest.updatables).isEmpty()
+    }
+
+    @Test
+    fun clear_clearAllUpdatables() {
+        val runtimeAnchor = FakeRuntimeAnchor(Pose())
+        val runtimeAnchor2 = FakeRuntimeAnchor(Pose())
+        val anchor = Anchor(runtimeAnchor, underTest)
+        val anchor2 = Anchor(runtimeAnchor2, underTest)
+        underTest.addUpdatable(anchor)
+        underTest.addUpdatable(anchor2)
+        check(underTest.updatables.isNotEmpty())
+
+        underTest.clear()
+
+        assertThat(underTest.updatables).isEmpty()
+    }
+
+    @Test
+    fun syncTrackables_replacesExistingTrackables() {
+        val runtimeTrackable1 = FakeRuntimePlane()
+        val runtimeTrackable2 = FakeRuntimePlane()
+        val runtimeTrackable3 = FakeRuntimePlane()
+        underTest.syncTrackables(listOf(runtimeTrackable1, runtimeTrackable2))
+        check(underTest.trackablesMap[runtimeTrackable1] != null)
+        check(underTest.trackablesMap[runtimeTrackable2] != null)
+        check(underTest.trackablesMap[runtimeTrackable3] == null)
+
+        underTest.syncTrackables(listOf(runtimeTrackable2, runtimeTrackable3))
+
+        assertThat(underTest.trackablesMap[runtimeTrackable1]).isNull()
+        assertThat(underTest.trackablesMap[runtimeTrackable2]).isNotNull()
+        assertThat(underTest.trackablesMap[runtimeTrackable3]).isNotNull()
+    }
+
+    @Test
+    fun clear_clearsAllTrackables() {
+        underTest.syncTrackables(listOf(FakeRuntimePlane()))
+        check(underTest.trackablesMap.isNotEmpty())
+
+        underTest.clear()
+
+        assertThat(underTest.trackablesMap).isEmpty()
+    }
+}
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/compose/compose-testing/api/current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/current.txt
copy to xr/compose/compose-testing/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/compose/compose-testing/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/compose/compose-testing/api/res-current.txt
diff --git a/xr/compose/compose-testing/api/restricted_current.txt b/xr/compose/compose-testing/api/restricted_current.txt
new file mode 100644
index 0000000..d738061
--- /dev/null
+++ b/xr/compose/compose-testing/api/restricted_current.txt
@@ -0,0 +1,91 @@
+// Signature format: 4.0
+package androidx.xr.compose.testing {
+
+  public final class SubspaceAssertionsKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertDepthIsAtLeast(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedMinDepth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertDepthIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedDepth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertHeightIsAtLeast(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedMinHeight);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertHeightIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedHeight);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static void assertIsEqualTo(float, float expected, String subject, optional float tolerance);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertLeftPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedLeft);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedX, float expectedY, float expectedZ);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertPositionIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedX, float expectedY, float expectedZ);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertRotationInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, androidx.xr.runtime.math.Quaternion expected);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertRotationIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, androidx.xr.runtime.math.Quaternion expected);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertTopPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedTop);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertWidthIsAtLeast(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedMinWidth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertWidthIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedWidth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertXPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedX);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertXPositionIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedX);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertYPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedY);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertYPositionIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedY);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertZPositionInRootIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedZ);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @com.google.errorprone.annotations.CanIgnoreReturnValue public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertZPositionIsEqualTo(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction, float expectedZ);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.runtime.math.Vector3 getPosition(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.runtime.math.Vector3 getPositionInRoot(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.runtime.math.Quaternion getRotation(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.runtime.math.Quaternion getRotationInRoot(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.unit.DpVolumeSize getSize(androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static float toDp(float);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static float toDp(int);
+  }
+
+  public final class SubspaceFiltersKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasAnyAncestor(androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasAnyChild(androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasAnyDescendant(androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasAnySibling(androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasContentDescription(String value, optional boolean substring, optional boolean ignoreCase);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasParent(androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher hasTestTag(String testTag);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher isFocusable();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher isFocused();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher isNotFocusable();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher isNotFocused();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsMatcher isRoot();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceSemanticsMatcher {
+    ctor public SubspaceSemanticsMatcher(String description, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.node.SubspaceSemanticsNode,java.lang.Boolean> matcher);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceSemanticsNodeInteraction {
+    ctor public SubspaceSemanticsNodeInteraction(androidx.xr.compose.testing.SubspaceTestContext testContext, androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method public void assertDoesNotExist();
+    method @com.google.errorprone.annotations.CanIgnoreReturnValue public androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction assertExists(optional String? errorMessageOnFail);
+    method public androidx.xr.compose.subspace.node.SubspaceSemanticsNode fetchSemanticsNode(optional String? errorMessageOnFail);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceSemanticsNodeInteractionCollection {
+    ctor public SubspaceSemanticsNodeInteractionCollection(androidx.xr.compose.testing.SubspaceTestContext testContext, androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceTestContext {
+    ctor public SubspaceTestContext(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,? extends java.lang.Object?> testRule);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceTestingActivity extends androidx.activity.ComponentActivity {
+    ctor public SubspaceTestingActivity();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions getExtensions();
+    method public androidx.xr.scenecore.Session getSession();
+    property public final androidx.xr.scenecore.testing.FakeXrExtensions extensions;
+    property public final androidx.xr.scenecore.Session session;
+  }
+
+  public final class SubspaceTestingActivityKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.scenecore.JxrPlatformAdapter createFakeRuntime(androidx.xr.compose.testing.SubspaceTestingActivity activity);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.scenecore.Session createFakeSession(androidx.xr.compose.testing.SubspaceTestingActivity activity, optional androidx.xr.scenecore.JxrPlatformAdapter runtime);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteractionCollection onAllSubspaceNodes(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteractionCollection onAllSubspaceNodesWithTag(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, String testTag);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction onSubspaceNode(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, androidx.xr.compose.testing.SubspaceSemanticsMatcher matcher);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.testing.SubspaceSemanticsNodeInteraction onSubspaceNodeWithTag(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, String testTag);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static void setSubspaceContent(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static void setSubspaceContent(androidx.compose.ui.test.junit4.AndroidComposeTestRule<? extends java.lang.Object?,androidx.xr.compose.testing.SubspaceTestingActivity>, kotlin.jvm.functions.Function0<kotlin.Unit> uiContent, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  public final class TestSetupKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void TestSetup(optional boolean isXrEnabled, optional boolean isFullSpace, optional androidx.xr.scenecore.JxrPlatformAdapter runtime, kotlin.jvm.functions.Function1<? super androidx.xr.scenecore.Session,kotlin.Unit> content);
+  }
+
+}
+
diff --git a/xr/compose/compose-testing/build.gradle b/xr/compose/compose-testing/build.gradle
new file mode 100644
index 0000000..313f888
--- /dev/null
+++ b/xr/compose/compose-testing/build.gradle
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.KotlinTarget
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+    id("AndroidXComposePlugin")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(project(":xr:compose:compose"))
+    api(project(":xr:runtime:runtime"))
+    api(project(":xr:scenecore:scenecore"))
+    api(project(":xr:scenecore:scenecore-testing"))
+
+    implementation(libs.mockitoKotlin)
+    implementation(libs.robolectric)
+    implementation(libs.testExtTruth)
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation("androidx.compose.runtime:runtime:1.2.1")
+    implementation("androidx.compose.ui:ui:1.2.1")
+    implementation("androidx.compose.ui:ui-unit:1.2.1")
+    implementation("androidx.compose.ui:ui-util:1.2.1")
+    implementation("androidx.compose.ui:ui-test-junit4:1.2.1")
+    implementation("com.google.ar:impress:0.0.1")
+}
+
+android {
+    defaultConfig {
+        // TODO: This should be lower, possibly 21.
+        //       Address API calls that require higher versions.
+        minSdkVersion 30
+    }
+    namespace = "androidx.xr.compose.testing"
+}
+
+androidx {
+    name = "XR Compose Testing"
+    type = LibraryType.PUBLISHED_KOTLIN_ONLY_TEST_LIBRARY
+    inceptionYear = "2024"
+    description = "Libraries to aid in unit testing Compose for XR clients."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceAssertions.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceAssertions.kt
new file mode 100644
index 0000000..39ca6de
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceAssertions.kt
@@ -0,0 +1,507 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import android.content.res.Resources
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.isUnspecified
+import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import com.google.errorprone.annotations.CanIgnoreReturnValue
+import kotlin.math.abs
+
+/**
+ * Asserts that the layout of this node has width equal to [expectedWidth].
+ *
+ * @param expectedWidth The width to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertWidthIsEqualTo(
+    expectedWidth: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.width.assertIsEqualTo(expectedWidth, "width") }
+}
+
+/**
+ * Asserts that the layout of this node has height equal to [expectedHeight].
+ *
+ * @param expectedHeight The height to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertHeightIsEqualTo(
+    expectedHeight: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.height.assertIsEqualTo(expectedHeight, "height") }
+}
+
+/**
+ * Asserts that the layout of this node has depth equal to [expectedDepth].
+ *
+ * @param expectedDepth The depth to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertDepthIsEqualTo(
+    expectedDepth: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.depth.assertIsEqualTo(expectedDepth, "depth") }
+}
+
+/**
+ * Asserts that the layout of this node has width that is greater than or equal to
+ * [expectedMinWidth].
+ *
+ * @param expectedMinWidth The minimum width to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertWidthIsAtLeast(
+    expectedMinWidth: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.width.assertIsAtLeast(expectedMinWidth, "width") }
+}
+
+/**
+ * Asserts that the layout of this node has height that is greater than or equal to
+ * [expectedMinHeight].
+ *
+ * @param expectedMinHeight The minimum height to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertHeightIsAtLeast(
+    expectedMinHeight: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.height.assertIsAtLeast(expectedMinHeight, "height") }
+}
+
+/**
+ * Asserts that the layout of this node has depth that is greater than or equal to
+ * [expectedMinDepth].
+ *
+ * @param expectedMinDepth The minimum depth to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertDepthIsAtLeast(
+    expectedMinDepth: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withSize { it.depth.assertIsAtLeast(expectedMinDepth, "depth") }
+}
+
+/**
+ * Asserts that the layout of this node has position in the root composable that is equal to the
+ * given position.
+ *
+ * @param expectedX The x position to assert.
+ * @param expectedY The y position to assert.
+ * @param expectedZ The z position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertPositionInRootIsEqualTo(
+    expectedX: Dp,
+    expectedY: Dp,
+    expectedZ: Dp,
+): SubspaceSemanticsNodeInteraction {
+
+    return withPositionInRoot {
+        it.x.toDp().assertIsEqualTo(expectedX, "x")
+        it.y.toDp().assertIsEqualTo(expectedY, "y")
+        it.z.toDp().assertIsEqualTo(expectedZ, "z")
+    }
+}
+
+/**
+ * Asserts that the layout of this node has the x position in the root composable that is equal to
+ * the given position.
+ *
+ * @param expectedX The x position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertXPositionInRootIsEqualTo(
+    expectedX: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPositionInRoot { it.x.toDp().assertIsEqualTo(expectedX, "x") }
+}
+
+/**
+ * Asserts that the layout of this node has the left position in the root composable that is equal
+ * to the given position.
+ *
+ * @param expectedLeft The left position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertLeftPositionInRootIsEqualTo(
+    expectedLeft: Dp
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve the node.")
+    (node.positionInRoot.x.toDp() - node.size.width.toDp() / 2.0f).assertIsEqualTo(
+        expectedLeft,
+        "left",
+    )
+    return this
+}
+
+/**
+ * Asserts that the layout of this node has the y position in the root composable that is equal to
+ * the given position.
+ *
+ * @param expectedY The y position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertYPositionInRootIsEqualTo(
+    expectedY: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPositionInRoot { it.y.toDp().assertIsEqualTo(expectedY, "y") }
+}
+
+/**
+ * Asserts that the layout of this node has the top position in the root composable that is equal to
+ * the given position.
+ *
+ * @param expectedTop The top position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertTopPositionInRootIsEqualTo(
+    expectedTop: Dp
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve the node.")
+    (node.positionInRoot.y.toDp() + node.size.height.toDp() / 2.0f).assertIsEqualTo(
+        expectedTop,
+        "top",
+    )
+    return this
+}
+
+/**
+ * Asserts that the layout of this node has the z position in the root composable that is equal to
+ * the given position.
+ *
+ * @param expectedZ The z position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertZPositionInRootIsEqualTo(
+    expectedZ: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPositionInRoot { it.z.toDp().assertIsEqualTo(expectedZ, "z") }
+}
+
+/**
+ * Asserts that the layout of this node has position that is equal to the given position.
+ *
+ * @param expectedX The x position to assert.
+ * @param expectedY The y position to assert.
+ * @param expectedZ The z position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertPositionIsEqualTo(
+    expectedX: Dp,
+    expectedY: Dp,
+    expectedZ: Dp,
+): SubspaceSemanticsNodeInteraction {
+
+    return withPosition {
+        it.x.toDp().assertIsEqualTo(expectedX, "x")
+        it.y.toDp().assertIsEqualTo(expectedY, "y")
+        it.z.toDp().assertIsEqualTo(expectedZ, "z")
+    }
+}
+
+/**
+ * Asserts that the layout of this node has the x position that is equal to the given position.
+ *
+ * @param expectedX The x position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertXPositionIsEqualTo(
+    expectedX: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPosition { it.x.toDp().assertIsEqualTo(expectedX, "x") }
+}
+
+/**
+ * Asserts that the layout of this node has the y position that is equal to the given position.
+ *
+ * @param expectedY The y position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertYPositionIsEqualTo(
+    expectedY: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPosition { it.y.toDp().assertIsEqualTo(expectedY, "y") }
+}
+
+/**
+ * Asserts that the layout of this node has the z position that is equal to the given position.
+ *
+ * @param expectedZ The z position to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertZPositionIsEqualTo(
+    expectedZ: Dp
+): SubspaceSemanticsNodeInteraction {
+    return withPosition { it.z.toDp().assertIsEqualTo(expectedZ, "z") }
+}
+
+/**
+ * Asserts that the layout of this node has rotation in the root composable that is equal to the
+ * given rotation.
+ *
+ * @param expected The rotation to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertRotationInRootIsEqualTo(
+    expected: Quaternion
+): SubspaceSemanticsNodeInteraction {
+
+    val makeError = { subject: String, exp: Float, actual: Float ->
+        "Actual $subject is $actual: expected $exp"
+    }
+
+    return withRotationInRoot {
+        check(it.x.equals(expected.x)) { makeError.invoke("x", expected.x, it.x) }
+        check(it.y.equals(expected.y)) { makeError.invoke("y", expected.y, it.y) }
+        check(it.z.equals(expected.z)) { makeError.invoke("z", expected.z, it.z) }
+        check(it.w.equals(expected.w)) { makeError.invoke("w", expected.w, it.w) }
+    }
+}
+
+/**
+ * Asserts that the layout of this node has rotation that is equal to the given rotation.
+ *
+ * @param expected The rotation to assert.
+ * @throws AssertionError if comparison fails.
+ */
+@CanIgnoreReturnValue
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.assertRotationIsEqualTo(
+    expected: Quaternion
+): SubspaceSemanticsNodeInteraction {
+
+    return withRotation {
+        check(it.x.equals(expected.x)) { "Actual x is ${it.x}: expected ${expected.x}" }
+        check(it.y.equals(expected.y)) { "Actual y is ${it.y}: expected ${expected.y}" }
+        check(it.z.equals(expected.z)) { "Actual z is ${it.z}: expected ${expected.z}" }
+        check(it.w.equals(expected.w)) { "Actual w is ${it.w}: expected ${expected.w}" }
+    }
+}
+
+/**
+ * Returns the size of the node.
+ *
+ * Additional assertions with custom tolerances may be performed on the individual values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.getSize(): DpVolumeSize {
+    lateinit var size: DpVolumeSize
+    withSize { size = it }
+    return size
+}
+
+/**
+ * Returns the position of the node relative to its parent layout node.
+ *
+ * Additional assertions with custom tolerances may be performed on the individual values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.getPosition(): Vector3 {
+    lateinit var position: Vector3
+    withPosition { position = it }
+    return position
+}
+
+/**
+ * Returns the position of the node relative to the root node.
+ *
+ * Additional assertions with custom tolerances may be performed on the individual values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.getPositionInRoot(): Vector3 {
+    lateinit var position: Vector3
+    withPositionInRoot { position = it }
+    return position
+}
+
+/**
+ * Returns the rotation of the node relative to its parent layout node.
+ *
+ * Additional assertions with custom tolerances may be performed on the individual values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.getRotation(): Quaternion {
+    lateinit var rotation: Quaternion
+    withRotation { rotation = it }
+    return rotation
+}
+
+/**
+ * Returns the rotation of the node relative to the root node.
+ *
+ * Additional assertions with custom tolerances may be performed on the individual values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceSemanticsNodeInteraction.getRotationInRoot(): Quaternion {
+    lateinit var rotation: Quaternion
+    withRotationInRoot { rotation = it }
+    return rotation
+}
+
+@CanIgnoreReturnValue
+private fun SubspaceSemanticsNodeInteraction.withSize(
+    assertion: (DpVolumeSize) -> Unit
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve size of the node.")
+    val size = node.size.let { DpVolumeSize(it.width.toDp(), it.height.toDp(), it.depth.toDp()) }
+    assertion.invoke(size)
+    return this
+}
+
+@CanIgnoreReturnValue
+private fun SubspaceSemanticsNodeInteraction.withPosition(
+    assertion: (Vector3) -> Unit
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve position of the node.")
+    assertion.invoke(node.position)
+    return this
+}
+
+@CanIgnoreReturnValue
+private fun SubspaceSemanticsNodeInteraction.withPositionInRoot(
+    assertion: (Vector3) -> Unit
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve position of the node.")
+    assertion.invoke(node.positionInRoot)
+    return this
+}
+
+@CanIgnoreReturnValue
+private fun SubspaceSemanticsNodeInteraction.withRotation(
+    assertion: (Quaternion) -> Unit
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve rotation of the node.")
+    assertion.invoke(node.rotation)
+    return this
+}
+
+@CanIgnoreReturnValue
+private fun SubspaceSemanticsNodeInteraction.withRotationInRoot(
+    assertion: (Quaternion) -> Unit
+): SubspaceSemanticsNodeInteraction {
+    val node = fetchSemanticsNode("Failed to retrieve rotation of the node.")
+    assertion.invoke(node.rotationInRoot)
+    return this
+}
+
+/**
+ * Returns if this value is equal to the [reference], within a given [tolerance]. If the reference
+ * value is [Float.NaN], [Float.POSITIVE_INFINITY] or [Float.NEGATIVE_INFINITY], this only returns
+ * true if this value is exactly the same (tolerance is disregarded).
+ */
+private fun Dp.isWithinTolerance(reference: Dp, tolerance: Dp): Boolean {
+    return when {
+        reference.isUnspecified -> this.isUnspecified
+        reference.value.isInfinite() -> this.value == reference.value
+        else -> abs(this.value - reference.value) <= tolerance.value
+    }
+}
+
+/**
+ * Asserts that this value is equal to the given [expected] value.
+ *
+ * Performs the comparison with the given [tolerance] or the default one if none is provided. It is
+ * recommended to use tolerance when comparing positions and size coming from the framework as there
+ * can be rounding operation performed by individual layouts so the values can be slightly off from
+ * the expected ones.
+ *
+ * @param expected The expected value to which this one should be equal to.
+ * @param subject Used in the error message to identify which item this assertion failed on.
+ * @param tolerance The tolerance within which the values should be treated as equal.
+ * @throws AssertionError if comparison fails.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Dp.assertIsEqualTo(expected: Dp, subject: String, tolerance: Dp = Dp(.5f)) {
+    if (!isWithinTolerance(expected, tolerance)) {
+        // Comparison failed, report the error in DPs
+        throw AssertionError("Actual $subject is $this, expected $expected (tolerance: $tolerance)")
+    }
+}
+
+/**
+ * Asserts that this value is greater than or equal to the given [expected] value.
+ *
+ * Performs the comparison with the given [tolerance] or the default one if none is provided. It is
+ * recommended to use tolerance when comparing positions and size coming from the framework as there
+ * can be rounding operation performed by individual layouts so the values can be slightly off from
+ * the expected ones.
+ *
+ * @param expected The expected value to which this one should be greater than or equal to.
+ * @param subject Used in the error message to identify which item this assertion failed on.
+ * @param tolerance The tolerance within which the values should be treated as equal.
+ * @throws AssertionError if comparison fails.
+ */
+private fun Dp.assertIsAtLeast(expected: Dp, subject: String, tolerance: Dp = Dp(.5f)) {
+    if (!(isWithinTolerance(expected, tolerance) || (!isUnspecified && this > expected))) {
+        // Comparison failed, report the error in DPs
+        throw AssertionError(
+            "Actual $subject is $this, expected at least $expected (tolerance: $tolerance)"
+        )
+    }
+}
+
+/** Converts a float to a [Dp] value. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Float.toDp(): Dp {
+    return Dp(this / Resources.getSystem().displayMetrics.density)
+}
+
+/** Converts an integer to a [Dp] value. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Int.toDp(): Dp {
+    return Dp(this.toFloat() / Resources.getSystem().displayMetrics.density)
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceFilters.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceFilters.kt
new file mode 100644
index 0000000..c8971c5
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceFilters.kt
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.util.fastAny
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+
+/**
+ * Verifies that the node is focusable.
+ *
+ * @return matcher that matches the node if it is focusable.
+ * @see SemanticsProperties.Focused
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun isFocusable(): SubspaceSemanticsMatcher = hasKey(SemanticsProperties.Focused)
+
+/**
+ * Verifies that the node is not focusable.
+ *
+ * @return matcher that matches the node if it is not focusable.
+ * @see SemanticsProperties.Focused
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun isNotFocusable(): SubspaceSemanticsMatcher =
+    SubspaceSemanticsMatcher.keyNotDefined(SemanticsProperties.Focused)
+
+/**
+ * Verifies that the node is focused.
+ *
+ * @return matcher that matches the node if it is focused.
+ * @see SemanticsProperties.Focused
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun isFocused(): SubspaceSemanticsMatcher =
+    SubspaceSemanticsMatcher.expectValue(SemanticsProperties.Focused, true)
+
+/**
+ * Verifies that the node is not focused.
+ *
+ * @return matcher that matches the node if it is not focused.
+ * @see SemanticsProperties.Focused
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun isNotFocused(): SubspaceSemanticsMatcher =
+    SubspaceSemanticsMatcher.expectValue(SemanticsProperties.Focused, false)
+
+/**
+ * Verifies the node's content description.
+ *
+ * @param value Value to match as one of the items in the list of content descriptions.
+ * @param substring Whether to use substring matching.
+ * @param ignoreCase Whether case should be ignored.
+ * @return true if the node's content description contains the given [value].
+ * @see SemanticsProperties.ContentDescription
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasContentDescription(
+    value: String,
+    substring: Boolean = false,
+    ignoreCase: Boolean = false,
+): SubspaceSemanticsMatcher {
+    return if (substring) {
+        SubspaceSemanticsMatcher(
+            "${SemanticsProperties.ContentDescription.name} contains '$value' " +
+                "(ignoreCase: $ignoreCase)"
+        ) {
+            it.config.getOrNull(SemanticsProperties.ContentDescription)?.any { item ->
+                item.contains(value, ignoreCase)
+            } ?: false
+        }
+    } else {
+        SubspaceSemanticsMatcher(
+            "${SemanticsProperties.ContentDescription.name} = '$value' (ignoreCase: $ignoreCase)"
+        ) {
+            it.config.getOrNull(SemanticsProperties.ContentDescription)?.any { item ->
+                item.equals(value, ignoreCase)
+            } ?: false
+        }
+    }
+}
+
+/**
+ * Verifies the node's test tag.
+ *
+ * @param testTag Value to match.
+ * @return true if the node is annotated by the given test tag.
+ * @see SemanticsProperties.TestTag
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasTestTag(testTag: String): SubspaceSemanticsMatcher =
+    SubspaceSemanticsMatcher.expectValue(SemanticsProperties.TestTag, testTag)
+
+/**
+ * Verifies that the node is the root semantics node.
+ *
+ * There is always one root in every node tree, added implicitly by Compose.
+ *
+ * @return true if the node is the root semantics node.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun isRoot(): SubspaceSemanticsMatcher = SubspaceSemanticsMatcher("isRoot") { it.isRoot }
+
+/**
+ * Verifies the node's parent.
+ *
+ * @param matcher The matcher to use to check the parent.
+ * @return true if the node's parent satisfies the given matcher.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasParent(matcher: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+    return SubspaceSemanticsMatcher("hasParentThat(${matcher.description})") {
+        it.parent?.run { matcher.matches(this) } ?: false
+    }
+}
+
+/**
+ * Verifies the node's children.
+ *
+ * @param matcher The matcher to use to check the children.
+ * @return true if the node has at least one child that satisfies the given matcher.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasAnyChild(matcher: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+    return SubspaceSemanticsMatcher("hasAnyChildThat(${matcher.description})") {
+        matcher.matchesAny(it.children)
+    }
+}
+
+/**
+ * Verifies the node's siblings.
+ *
+ * @param matcher The matcher to use to check the siblings. Sibling is defined as a any other node
+ *   that shares the same parent.
+ * @return true if the node has at least one sibling that satisfies the given matcher.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasAnySibling(matcher: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+    return SubspaceSemanticsMatcher("hasAnySiblingThat(${matcher.description})") {
+        val node = it
+        it.parent?.run { matcher.matchesAny(this.children.filter { child -> child.id != node.id }) }
+            ?: false
+    }
+}
+
+/**
+ * Verifies the node's ancestors.
+ *
+ * @param matcher The matcher to use to check the ancestors. Example: For the following tree
+ *
+ * ```
+ * |-X
+ * |-A
+ *   |-B
+ *     |-C1
+ *     |-C2
+ * ```
+ *
+ * In case of C1, we would check the matcher against A and B.
+ *
+ * @return true if the node has at least one ancestor that satisfies the given matcher.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasAnyAncestor(matcher: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+    return SubspaceSemanticsMatcher("hasAnyAncestorThat(${matcher.description})") {
+        matcher.matchesAny(it.ancestors)
+    }
+}
+
+/**
+ * Verifies the node's descendants.
+ *
+ * @param matcher The matcher to use to check the descendants. Example: For the following tree
+ *
+ * ```
+ * |-X
+ * |-A
+ *   |-B
+ *     |-C1
+ *     |-C2
+ * ```
+ *
+ * In case of A, we would check the matcher against B, C1 and C2.
+ *
+ * @return true if the node has at least one descendant that satisfies the given matcher.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun hasAnyDescendant(matcher: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+    fun checkIfSubtreeMatches(
+        matcher: SubspaceSemanticsMatcher,
+        node: SubspaceSemanticsNode,
+    ): Boolean {
+        if (matcher.matchesAny(node.children)) {
+            return true
+        }
+
+        return node.children.fastAny { checkIfSubtreeMatches(matcher, it) }
+    }
+
+    return SubspaceSemanticsMatcher("hasAnyDescendantThat(${matcher.description})") {
+        checkIfSubtreeMatches(matcher, it)
+    }
+}
+
+private val SubspaceSemanticsNode.ancestors: Iterable<SubspaceSemanticsNode>
+    get() =
+        object : Iterable<SubspaceSemanticsNode> {
+            override fun iterator(): Iterator<SubspaceSemanticsNode> {
+                return object : Iterator<SubspaceSemanticsNode> {
+                    var next = parent
+
+                    override fun hasNext(): Boolean {
+                        return next != null
+                    }
+
+                    override fun next(): SubspaceSemanticsNode {
+                        return next!!.also { next = it.parent }
+                    }
+                }
+            }
+        }
+
+private fun hasKey(key: SemanticsPropertyKey<*>): SubspaceSemanticsMatcher =
+    SubspaceSemanticsMatcher.keyIsDefined(key)
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsMatcher.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsMatcher.kt
new file mode 100644
index 0000000..4c4c73a
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsMatcher.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+
+/**
+ * Wrapper for semantics matcher lambdas that allows to build string explaining to the developer
+ * what conditions were being tested.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceSemanticsMatcher(
+    internal val description: String,
+    private val matcher: (SubspaceSemanticsNode) -> Boolean,
+) {
+
+    internal companion object {
+        /**
+         * Builds a predicate that tests whether the value of the given [key] is equal to
+         * [expectedValue].
+         */
+        internal fun <T> expectValue(
+            key: SemanticsPropertyKey<T>,
+            expectedValue: T,
+        ): SubspaceSemanticsMatcher {
+            return SubspaceSemanticsMatcher("${key.name} = '$expectedValue'") {
+                it.config.getOrElseNullable(key) { null } == expectedValue
+            }
+        }
+
+        /** Builds a predicate that tests whether the given [key] is defined in semantics. */
+        internal fun <T> keyIsDefined(key: SemanticsPropertyKey<T>): SubspaceSemanticsMatcher {
+            return SubspaceSemanticsMatcher("${key.name} is defined") { key in it.config }
+        }
+
+        /** Builds a predicate that tests whether the given [key] is NOT defined in semantics. */
+        internal fun <T> keyNotDefined(key: SemanticsPropertyKey<T>): SubspaceSemanticsMatcher {
+            return SubspaceSemanticsMatcher("${key.name} is NOT defined") { key !in it.config }
+        }
+    }
+
+    /** Returns whether the given node is matched by this matcher. */
+    internal fun matches(node: SubspaceSemanticsNode): Boolean {
+        return matcher(node)
+    }
+
+    /** Returns whether at least one of the given nodes is matched by this matcher. */
+    internal fun matchesAny(nodes: Iterable<SubspaceSemanticsNode>): Boolean {
+        return nodes.any(matcher)
+    }
+
+    internal infix fun and(other: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+        return SubspaceSemanticsMatcher("($description) && (${other.description})") {
+            matcher(it) && other.matches(it)
+        }
+    }
+
+    internal infix fun or(other: SubspaceSemanticsMatcher): SubspaceSemanticsMatcher {
+        return SubspaceSemanticsMatcher("($description) || (${other.description})") {
+            matcher(it) || other.matches(it)
+        }
+    }
+
+    internal operator fun not(): SubspaceSemanticsMatcher {
+        return SubspaceSemanticsMatcher("NOT ($description)") { !matcher(it) }
+    }
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsNodeInteraction.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsNodeInteraction.kt
new file mode 100644
index 0000000..cdad0f4
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsNodeInteraction.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+import com.google.errorprone.annotations.CanIgnoreReturnValue
+
+/**
+ * Represents a semantics node and the path to fetch it from the semantics tree. One can perform
+ * assertions or navigate to other nodes such as [onChildren].
+ *
+ * An instance of [SubspaceSemanticsNodeInteraction] can be obtained from [onSubspaceNode] and
+ * convenience methods that use a specific filter.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceSemanticsNodeInteraction
+internal constructor(
+    private val testContext: SubspaceTestContext,
+    private val selector: SubspaceSemanticsSelector,
+) {
+    public constructor(
+        testContext: SubspaceTestContext,
+        matcher: SubspaceSemanticsMatcher,
+    ) : this(testContext, SubspaceSemanticsSelector(matcher))
+
+    private fun fetchSemanticsNodes(
+        atLeastOneRootRequired: Boolean,
+        errorMessageOnFail: String? = null,
+    ): SubspaceSelectionResult {
+        return selector.map(
+            testContext.getAllSemanticsNodes(atLeastOneRootRequired = atLeastOneRootRequired),
+            errorMessageOnFail.orEmpty(),
+        )
+    }
+
+    /**
+     * Returns the semantics node captured by this object.
+     *
+     * Note: Accessing this object involves synchronization with your UI. If you are accessing this
+     * multiple times in one atomic operation, it is better to cache the result instead of calling
+     * this API multiple times.
+     *
+     * This will fail if there is 0 or multiple nodes matching.
+     *
+     * @param errorMessageOnFail Error message prefix to be added to the message in case this fetch
+     *   fails. This is typically used by operations that rely on this assert. Example prefix could
+     *   be: "Failed to perform doOnClick.".
+     * @throws [AssertionError] if 0 or multiple nodes found.
+     */
+    public fun fetchSemanticsNode(errorMessageOnFail: String? = null): SubspaceSemanticsNode {
+        return fetchOneOrThrow(errorMessageOnFail)
+    }
+
+    /**
+     * Asserts that no item was found or that the item is no longer in the hierarchy.
+     *
+     * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
+     *
+     * @throws [AssertionError] if the assert fails.
+     */
+    public fun assertDoesNotExist() {
+        val result =
+            fetchSemanticsNodes(
+                atLeastOneRootRequired = false,
+                errorMessageOnFail = "Failed: assertDoesNotExist.",
+            )
+        if (result.selectedNodes.isNotEmpty()) {
+            throw AssertionError(
+                "Failed: assertDoesNotExist. Expected 0 but found ${result.selectedNodes.size} nodes."
+            )
+        }
+    }
+
+    /**
+     * Asserts that the component was found and is part of the component tree.
+     *
+     * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
+     * If you are using [fetchSemanticsNode] you don't need to call this. In fact you would just
+     * introduce additional overhead.
+     *
+     * @param errorMessageOnFail Error message prefix to be added to the message in case this assert
+     *   fails. This is typically used by operations that rely on this assert. Example prefix could
+     *   be: "Failed to perform doOnClick.".
+     * @throws [AssertionError] if the assert fails.
+     */
+    @CanIgnoreReturnValue
+    public fun assertExists(errorMessageOnFail: String? = null): SubspaceSemanticsNodeInteraction {
+        fetchOneOrThrow(errorMessageOnFail)
+        return this
+    }
+
+    @CanIgnoreReturnValue
+    private fun fetchOneOrThrow(errorMessageOnFail: String? = null): SubspaceSemanticsNode {
+        val finalErrorMessage = errorMessageOnFail ?: "Failed: assertExists."
+
+        val result =
+            fetchSemanticsNodes(
+                atLeastOneRootRequired = true,
+                errorMessageOnFail = finalErrorMessage
+            )
+        if (result.selectedNodes.count() != 1) {
+            if (result.customErrorOnNoMatch != null) {
+                throw AssertionError(finalErrorMessage + "\n" + result.customErrorOnNoMatch)
+            }
+
+            throw AssertionError(finalErrorMessage)
+        }
+
+        return result.selectedNodes.first()
+    }
+}
+
+/**
+ * Represents a collection of semantics nodes and the path to fetch them from the semantics tree.
+ * One can interact with these nodes by performing assertions such as [assertCountEquals], or
+ * navigate to other nodes such as [get].
+ *
+ * An instance of [SubspaceSemanticsNodeInteractionCollection] can be obtained from
+ * [onAllSubspaceNodes] and convenience methods that use a specific filter, such as
+ * [onAllNodesWithText].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceSemanticsNodeInteractionCollection
+private constructor(
+    internal val testContext: SubspaceTestContext,
+    internal val selector: SubspaceSemanticsSelector,
+) {
+    @Suppress("PrimitiveInCollection") private var nodeIds: List<Int>? = null
+
+    public constructor(
+        testContext: SubspaceTestContext,
+        matcher: SubspaceSemanticsMatcher,
+    ) : this(testContext, SubspaceSemanticsSelector(matcher))
+
+    /**
+     * Returns the semantics nodes captured by this object.
+     *
+     * Note: Accessing this object involves synchronization with your UI. If you are accessing this
+     * multiple times in one atomic operation, it is better to cache the result instead of calling
+     * this API multiple times.
+     *
+     * @param atLeastOneRootRequired Whether to throw an error in case there is no compose content
+     *   in the current test app.
+     * @param errorMessageOnFail Error message prefix to be added to the message in case this fetch
+     *   fails. This is typically used by operations that rely on this assert. Example prefix could
+     *   be: "Failed to perform doOnClick.".
+     */
+    private fun fetchSemanticsNodes(
+        atLeastOneRootRequired: Boolean = true,
+        errorMessageOnFail: String? = null,
+    ): List<SubspaceSemanticsNode> {
+        if (nodeIds == null) {
+            return selector
+                .map(
+                    testContext.getAllSemanticsNodes(atLeastOneRootRequired),
+                    errorMessageOnFail.orEmpty()
+                )
+                .apply { nodeIds = selectedNodes.map { it.id }.toList() }
+                .selectedNodes
+        }
+
+        return testContext.getAllSemanticsNodes(atLeastOneRootRequired).filter {
+            it.id in nodeIds!!
+        }
+    }
+
+    /**
+     * Retrieve node at the given index of this collection.
+     *
+     * Any subsequent operation on its result will expect exactly one element found (unless
+     * [SubspaceSemanticsNodeInteraction.assertDoesNotExist] is used) and will throw
+     * [AssertionError] if none or more than one element is found.
+     */
+    private operator fun get(index: Int): SubspaceSemanticsNodeInteraction {
+        return SubspaceSemanticsNodeInteraction(testContext, selector.addIndexSelector(index))
+    }
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsSelector.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsSelector.kt
new file mode 100644
index 0000000..891d0a8d
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceSemanticsSelector.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+
+/**
+ * Projects the given set of nodes to a new set of nodes.
+ *
+ * @param description Description that is displayed to the developer in error outputs.
+ * @param requiresExactlyOneNode Whether this selector should expect to receive exactly 1 node.
+ * @param chainedInputSelector Optional selector to apply before this selector gets applied.
+ * @param selector The lambda that implements the projection.
+ */
+internal class SubspaceSemanticsSelector(
+    internal val description: String,
+    private val requiresExactlyOneNode: Boolean,
+    private val chainedInputSelector: SubspaceSemanticsSelector? = null,
+    private val selector: (Iterable<SubspaceSemanticsNode>) -> SubspaceSelectionResult,
+) {
+
+    /**
+     * Maps the given list of nodes to a new list of nodes.
+     *
+     * @throws AssertionError if required prerequisites to perform the selection were not satisfied.
+     */
+    internal fun map(
+        nodes: Iterable<SubspaceSemanticsNode>,
+        errorOnFail: String,
+    ): SubspaceSelectionResult {
+        val chainedResult = chainedInputSelector?.map(nodes, errorOnFail)
+        val inputNodes = chainedResult?.selectedNodes ?: nodes
+        if (requiresExactlyOneNode && inputNodes.count() != 1) {
+            throw AssertionError(
+                chainedResult?.customErrorOnNoMatch
+                    ?: "Required exactly one node but found ${inputNodes.count()} nodes."
+            )
+        }
+        return selector(inputNodes)
+    }
+}
+
+/** Creates a new [SubspaceSemanticsSelector] based on the given [SubspaceSemanticsMatcher]. */
+internal fun SubspaceSemanticsSelector(
+    matcher: SubspaceSemanticsMatcher
+): SubspaceSemanticsSelector {
+    return SubspaceSemanticsSelector(
+        matcher.description,
+        requiresExactlyOneNode = false,
+        chainedInputSelector = null,
+    ) { nodes ->
+        SubspaceSelectionResult(nodes.filter { matcher.matches(it) })
+    }
+}
+
+/**
+ * Result of [SubspaceSemanticsSelector] projection.
+ *
+ * @param selectedNodes The result nodes found.
+ * @param customErrorOnNoMatch If the projection failed to map nodes due to wrong input (e.g.
+ *   selector expected only 1 node but got multiple) it will provide a custom error exactly
+ *   explaining what selection was performed and what nodes it received.
+ */
+internal class SubspaceSelectionResult(
+    internal val selectedNodes: List<SubspaceSemanticsNode>,
+    internal val customErrorOnNoMatch: String? = null,
+)
+
+/**
+ * Chains the given selector to be performed after this one.
+ *
+ * The new selector will expect to receive exactly one node (otherwise will fail).
+ */
+internal fun SubspaceSemanticsSelector.addSelectionFromSingleNode(
+    description: String,
+    selector: (SubspaceSemanticsNode) -> List<SubspaceSemanticsNode>,
+): SubspaceSemanticsSelector {
+    return SubspaceSemanticsSelector(
+        "(${this.description}).$description",
+        requiresExactlyOneNode = true,
+        chainedInputSelector = this,
+    ) { nodes ->
+        SubspaceSelectionResult(selector(nodes.first()))
+    }
+}
+
+/** Chains a new selector that retrieves node from this selector at the given [index]. */
+internal fun SubspaceSemanticsSelector.addIndexSelector(index: Int): SubspaceSemanticsSelector {
+    return SubspaceSemanticsSelector(
+        "(${this.description})[$index]",
+        requiresExactlyOneNode = false,
+        chainedInputSelector = this,
+    ) { nodes ->
+        val nodesList = nodes.toList()
+        if (index >= 0 && index < nodesList.size) {
+            SubspaceSelectionResult(listOf(nodesList[index]))
+        } else {
+            val errorMessage = "Index out of bounds: $index"
+            SubspaceSelectionResult(emptyList(), errorMessage)
+        }
+    }
+}
+
+/** Chains a new selector that retrieves the last node returned from this selector. */
+internal fun SubspaceSemanticsSelector.addLastNodeSelector(): SubspaceSemanticsSelector {
+    return SubspaceSemanticsSelector(
+        "(${this.description}).last",
+        requiresExactlyOneNode = false,
+        chainedInputSelector = this,
+    ) { nodes ->
+        SubspaceSelectionResult(nodes.toList().takeLast(1))
+    }
+}
+
+/**
+ * Chains a new selector that selects all the nodes matching the given [matcher] from the nodes
+ * returned by this selector.
+ */
+internal fun SubspaceSemanticsSelector.addSelectorViaMatcher(
+    selectorName: String,
+    matcher: SubspaceSemanticsMatcher,
+): SubspaceSemanticsSelector {
+    return SubspaceSemanticsSelector(
+        "(${this.description}).$selectorName(${matcher.description})",
+        requiresExactlyOneNode = false,
+        chainedInputSelector = this,
+    ) { nodes ->
+        SubspaceSelectionResult(nodes.filter { matcher.matches(it) })
+    }
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestContext.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestContext.kt
new file mode 100644
index 0000000..a20cf18
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestContext.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.util.fastForEach
+import androidx.xr.compose.platform.SceneManager
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceTestContext(private val testRule: AndroidComposeTestRule<*, *>) {
+    /**
+     * Collects all [SubspaceSemanticsNode]s from all compose hierarchies.
+     *
+     * Can crash in case it hits time out. This is not supposed to be handled as it surfaces only in
+     * incorrect tests.
+     */
+    internal fun getAllSemanticsNodes(
+        atLeastOneRootRequired: Boolean
+    ): Iterable<SubspaceSemanticsNode> {
+        // Block and wait for compose state to settle before looking for root nodes.
+        testRule.waitForIdle()
+        val roots = SceneManager.getAllRootSubspaceSemanticsNodes()
+        check(!atLeastOneRootRequired || roots.isNotEmpty()) {
+            """No subspace compose hierarchies found in the app. Possible reasons include:
+        (1) the Activity that calls setSubspaceContent did not launch;
+        (2) setSubspaceContent was not called;
+        (3) setSubspaceContent was called before the ComposeTestRule ran;
+        (4) a Subspace was not used in setContent
+        If setSubspaceContent is called by the Activity, make sure the Activity is
+        launched after the ComposeTestRule runs"""
+        }
+
+        return roots.flatMap { it.getAllSemanticsNodes() }
+    }
+}
+
+private fun SubspaceSemanticsNode.getAllSemanticsNodes(): Iterable<SubspaceSemanticsNode> {
+    val nodes = mutableListOf<SubspaceSemanticsNode>()
+
+    fun findAllSemanticNodesRecursive(currentNode: SubspaceSemanticsNode) {
+        nodes.add(currentNode)
+        currentNode.children.fastForEach { child -> findAllSemanticNodesRecursive(child) }
+    }
+
+    findAllSemanticNodesRecursive(this)
+
+    return nodes
+}
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestingActivity.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestingActivity.kt
new file mode 100644
index 0000000..6704c65
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/SubspaceTestingActivity.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import android.view.Display
+import androidx.activity.ComponentActivity
+import androidx.annotation.NonNull
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.xr.compose.platform.SceneManager
+import androidx.xr.compose.platform.setSubspaceContent
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.scenecore.JxrPlatformAdapter
+import androidx.xr.scenecore.Session
+import androidx.xr.scenecore.impl.JxrPlatformAdapterAxr
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary
+import androidx.xr.scenecore.testing.FakeImpressApi
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService
+import androidx.xr.scenecore.testing.FakeXrExtensions
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer
+import org.mockito.Mockito.mock
+import org.robolectric.shadows.ShadowDisplay
+
+/** Custom test class that should be used for testing [SubspaceComposable] content. */
+@Suppress("ForbiddenSuperClass")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceTestingActivity : ComponentActivity() {
+    public val extensions: FakeXrExtensions = FakeXrExtensions()
+    public val session: Session by lazy { createFakeSession(this) }
+
+    /** Throws an exception by default under test; return Robolectric Display impl instead. */
+    @NonNull override fun getDisplay(): Display = ShadowDisplay.getDefaultDisplay()
+
+    override fun onStart() {
+        SceneManager.start()
+        super.onStart()
+    }
+
+    override fun onDestroy() {
+        SceneManager.stop()
+        super.onDestroy()
+    }
+}
+
+/** Analog to [AndroidComposeTestRule.setContent] for testing [SubspaceComposable] content. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.setSubspaceContent(
+    content: @Composable @SubspaceComposable () -> Unit
+) {
+    setContent {} // Necessary to avoid crashes, as ComposeTestRule expects a call to setContent {}
+    activity.setSubspaceContent(
+        session = activity.session,
+        enableXrForTesting = true,
+        content = content,
+    )
+}
+
+/** Analog to [AndroidComposeTestRule.setContent] for testing [SubspaceComposable] content. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.setSubspaceContent(
+    uiContent: @Composable () -> Unit,
+    content: @Composable @SubspaceComposable () -> Unit,
+) {
+    setContent(uiContent)
+    activity.setSubspaceContent(
+        session = activity.session,
+        enableXrForTesting = true,
+        content = content,
+    )
+}
+
+/** Subspace version of onNode in Compose. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.onSubspaceNode(
+    matcher: SubspaceSemanticsMatcher
+): SubspaceSemanticsNodeInteraction =
+    SubspaceSemanticsNodeInteraction(SubspaceTestContext(this), matcher)
+
+/** Subspace version of onAllNodes in Compose. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.onAllSubspaceNodes(
+    matcher: SubspaceSemanticsMatcher
+): SubspaceSemanticsNodeInteractionCollection =
+    SubspaceSemanticsNodeInteractionCollection(SubspaceTestContext(this), matcher)
+
+/** Subspace version of onNodeWithTag in Compose. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.onSubspaceNodeWithTag(
+    testTag: String
+): SubspaceSemanticsNodeInteraction = onSubspaceNode(hasTestTag(testTag))
+
+/** Subspace version of onAllNodesWithTag in Compose. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun AndroidComposeTestRule<*, SubspaceTestingActivity>.onAllSubspaceNodesWithTag(
+    testTag: String
+): SubspaceSemanticsNodeInteractionCollection = onAllSubspaceNodes(hasTestTag(testTag))
+
+/**
+ * Create a fake [Session] for testing.
+ *
+ * TODO(b/370856223) Update documentation to include params
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun createFakeSession(
+    activity: SubspaceTestingActivity,
+    runtime: JxrPlatformAdapter = createFakeRuntime(activity),
+): Session = Session.create(activity, runtime)
+
+/**
+ * Create a fake [JxrPlatformAdapter] for testing.
+ *
+ * TODO(b/370856223) Update documentation to include params
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun createFakeRuntime(activity: SubspaceTestingActivity): JxrPlatformAdapter =
+    JxrPlatformAdapterAxr.create(
+        /* activity = */ activity,
+        /* executor = */ FakeScheduledExecutorService(),
+        /* extensions = */ activity.extensions,
+        /* impressApi = */ FakeImpressApi(),
+        /* perceptionLibrary = */ PerceptionLibrary(),
+        /* splitEngineSubspaceManager = */ mock(SplitEngineSubspaceManager::class.java),
+        /* splitEngineRenderer = */ mock(ImpSplitEngineRenderer::class.java),
+    )
diff --git a/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/TestSetup.kt b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/TestSetup.kt
new file mode 100644
index 0000000..6a7737a
--- /dev/null
+++ b/xr/compose/compose-testing/src/main/kotlin/androidx/xr/compose/testing/TestSetup.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.testing
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.xr.compose.platform.LocalHasXrSpatialFeature
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.scenecore.JxrPlatformAdapter
+import androidx.xr.scenecore.Session
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+/**
+ * A Test environment composable wrapper to support testing elevated components locally
+ *
+ * TODO(b/370856223) Update documentation
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun TestSetup(
+    isXrEnabled: Boolean = true,
+    isFullSpace: Boolean = true,
+    runtime: JxrPlatformAdapter =
+        createFakeRuntime(LocalContext.current.getActivity() as SubspaceTestingActivity),
+    content: @Composable Session.() -> Unit,
+) {
+    val activity = LocalContext.current.getActivity() as SubspaceTestingActivity
+    val session = remember {
+        if (isXrEnabled) {
+            createFakeSession(activity, runtime).apply {
+                if (isFullSpace) {
+                    requestFullSpaceMode()
+                } else {
+                    requestHomeSpaceMode()
+                }
+            }
+        } else {
+            createNonXrSession(activity)
+        }
+    }
+    CompositionLocalProvider(
+        LocalSession provides session,
+        LocalHasXrSpatialFeature provides isXrEnabled,
+    ) {
+        session.content()
+    }
+}
+
+private fun createNonXrSession(activity: Activity): Session {
+    return Session.create(
+        activity,
+        mock<JxrPlatformAdapter> {
+            on { spatialEnvironment } doReturn mock<JxrPlatformAdapter.SpatialEnvironment>()
+            on { activitySpace } doReturn
+                mock<JxrPlatformAdapter.ActivitySpace>(
+                    defaultAnswer = { throw UnsupportedOperationException() }
+                )
+            on { headActivityPose } doReturn mock<JxrPlatformAdapter.HeadActivityPose>()
+            on { perceptionSpaceActivityPose } doReturn
+                mock<JxrPlatformAdapter.PerceptionSpaceActivityPose>(
+                    defaultAnswer = { throw UnsupportedOperationException() }
+                )
+            on { mainPanelEntity } doReturn mock<JxrPlatformAdapter.PanelEntity>()
+            on { requestHomeSpaceMode() } doAnswer { throw UnsupportedOperationException() }
+            on { requestFullSpaceMode() } doAnswer { throw UnsupportedOperationException() }
+            on { createActivityPanelEntity(any(), any(), any(), any(), any()) } doAnswer
+                {
+                    throw UnsupportedOperationException()
+                }
+            on { createAnchorEntity(any(), any(), any(), any()) } doAnswer
+                {
+                    throw UnsupportedOperationException()
+                }
+            on { createEntity(any(), any(), any()) } doAnswer
+                {
+                    throw UnsupportedOperationException()
+                }
+            on { createGltfEntity(any(), any(), any()) } doAnswer
+                {
+                    throw UnsupportedOperationException()
+                }
+            on { createPanelEntity(any(), any(), any(), any(), any(), any(), any()) } doAnswer
+                {
+                    throw UnsupportedOperationException()
+                }
+            on { createLoggingEntity(any()) } doAnswer { throw UnsupportedOperationException() }
+        },
+    )
+}
+
+private tailrec fun Context.getActivity(): Activity =
+    when (this) {
+        is Activity -> this
+        is ContextWrapper -> baseContext.getActivity()
+        else -> error("Unexpected Context type when trying to resolve the context's Activity.")
+    }
diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt
new file mode 100644
index 0000000..c1c118f
--- /dev/null
+++ b/xr/compose/compose/api/current.txt
@@ -0,0 +1,134 @@
+// Signature format: 4.0
+package androidx.xr.compose.unit {
+
+  public final class DpVolumeSize {
+    ctor public DpVolumeSize(float width, float height, float depth);
+    method public float getDepth();
+    method public float getHeight();
+    method public float getWidth();
+    property public float depth;
+    property public float height;
+    property public float width;
+    field public static final androidx.xr.compose.unit.DpVolumeSize.Companion Companion;
+  }
+
+  public static final class DpVolumeSize.Companion {
+    method public androidx.xr.compose.unit.DpVolumeSize getZero();
+    property public final androidx.xr.compose.unit.DpVolumeSize Zero;
+  }
+
+  public final class IntVolumeSize {
+    ctor public IntVolumeSize(int width, int height, int depth);
+    method public int getDepth();
+    method public int getHeight();
+    method public int getWidth();
+    property public final int depth;
+    property public final int height;
+    property public final int width;
+    field public static final androidx.xr.compose.unit.IntVolumeSize.Companion Companion;
+  }
+
+  public static final class IntVolumeSize.Companion {
+    method public androidx.xr.compose.unit.IntVolumeSize getZero();
+    property public final androidx.xr.compose.unit.IntVolumeSize Zero;
+  }
+
+  @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class Meter implements java.lang.Comparable<androidx.xr.compose.unit.Meter> {
+    ctor public Meter(float value);
+    method public int compareTo(float other);
+    method public inline operator float div(double other);
+    method public inline operator float div(float other);
+    method public inline operator float div(int other);
+    method public float getValue();
+    method public inline operator float minus(float other);
+    method public inline operator float plus(float other);
+    method public inline int roundToPx(androidx.compose.ui.unit.Density density);
+    method public inline operator float times(double other);
+    method public inline operator float times(float other);
+    method public inline operator float times(int other);
+    method public inline float toCm();
+    method public inline float toDp();
+    method public inline float toM();
+    method public inline float toMm();
+    method public inline float toPx(androidx.compose.ui.unit.Density density);
+    property public final inline boolean isFinite;
+    property public final inline boolean isSpecified;
+    property public final float value;
+    field public static final androidx.xr.compose.unit.Meter.Companion Companion;
+  }
+
+  public static final class Meter.Companion {
+    method public inline float fromPixel(float px, androidx.compose.ui.unit.Density density);
+    method public float getCentimeters();
+    method public float getCentimeters();
+    method public float getCentimeters();
+    method public float getInfinity();
+    method public float getMeters();
+    method public float getMeters();
+    method public float getMeters();
+    method public float getMillimeters();
+    method public float getMillimeters();
+    method public float getMillimeters();
+    method public float getNaN();
+    property public float Infinity;
+    property public float NaN;
+    property public float centimeters;
+    property public float centimeters;
+    property public float centimeters;
+    property public float meters;
+    property public float meters;
+    property public float meters;
+    property public float millimeters;
+    property public float millimeters;
+    property public float millimeters;
+  }
+
+  public final class MeterKt {
+    method public static inline operator float div(double, float other);
+    method public static inline operator float div(float, float other);
+    method public static inline operator float div(int, float other);
+    method public static inline operator float times(double, float other);
+    method public static inline operator float times(float, float other);
+    method public static inline operator float times(int, float other);
+    method public static inline float toMeter(float);
+  }
+
+  public final class VolumeConstraints {
+    ctor public VolumeConstraints(int minWidth, int maxWidth, int minHeight, int maxHeight, optional int minDepth, optional int maxDepth);
+    method public androidx.xr.compose.unit.VolumeConstraints copy(optional int minWidth, optional int maxWidth, optional int minHeight, optional int maxHeight, optional int minDepth, optional int maxDepth);
+    method public int getMaxDepth();
+    method public int getMaxHeight();
+    method public int getMaxWidth();
+    method public int getMinDepth();
+    method public int getMinHeight();
+    method public int getMinWidth();
+    method public boolean hasBoundedDepth();
+    method public boolean hasBoundedHeight();
+    method public boolean hasBoundedWidth();
+    property public final boolean hasBoundedDepth;
+    property public final boolean hasBoundedHeight;
+    property public final boolean hasBoundedWidth;
+    property public final int maxDepth;
+    property public final int maxHeight;
+    property public final int maxWidth;
+    property public final int minDepth;
+    property public final int minHeight;
+    property public final int minWidth;
+    field public static final androidx.xr.compose.unit.VolumeConstraints.Companion Companion;
+    field public static final int INFINITY = 2147483647; // 0x7fffffff
+  }
+
+  public static final class VolumeConstraints.Companion {
+    property public static final int INFINITY;
+  }
+
+  public final class VolumeConstraintsKt {
+    method public static androidx.xr.compose.unit.VolumeConstraints constrain(androidx.xr.compose.unit.VolumeConstraints, androidx.xr.compose.unit.VolumeConstraints otherConstraints);
+    method public static int constrainDepth(androidx.xr.compose.unit.VolumeConstraints, int depth);
+    method public static int constrainHeight(androidx.xr.compose.unit.VolumeConstraints, int height);
+    method public static int constrainWidth(androidx.xr.compose.unit.VolumeConstraints, int width);
+    method public static androidx.xr.compose.unit.VolumeConstraints offset(androidx.xr.compose.unit.VolumeConstraints, optional int horizontal, optional int vertical, optional int depth, optional boolean resetMins);
+  }
+
+}
+
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/compose/compose/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/compose/compose/api/res-current.txt
diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt
new file mode 100644
index 0000000..8d9457c
--- /dev/null
+++ b/xr/compose/compose/api/restricted_current.txt
@@ -0,0 +1,843 @@
+// Signature format: 4.0
+package androidx.xr.compose.platform {
+
+  public final class ActivityKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static void setSubspaceContent(androidx.activity.ComponentActivity, optional androidx.xr.scenecore.Session? session, optional boolean enableXrForTesting, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  public final class LocalSessionKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.xr.scenecore.Session?> getLocalSession();
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.xr.scenecore.Session?> LocalSession;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Logger {
+    method public void init(android.content.Context context);
+    method public void log(String tag, kotlin.jvm.functions.Function0<java.lang.String> messageGenerator);
+    field public static final androidx.xr.compose.platform.Logger INSTANCE;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SceneManager implements java.lang.AutoCloseable {
+    method public void close();
+    method public java.util.List<androidx.xr.compose.subspace.node.SubspaceSemanticsNode> getAllRootSubspaceSemanticsNodes();
+    method public int getSceneCount();
+    method public void start();
+    method public void stop();
+    field public static final androidx.xr.compose.platform.SceneManager INSTANCE;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface SessionCallbackProvider {
+    method public operator androidx.xr.compose.platform.SessionCallbacks get(androidx.xr.scenecore.Session session);
+    field public static final androidx.xr.compose.platform.SessionCallbackProvider.Companion Companion;
+  }
+
+  public static final class SessionCallbackProvider.Companion {
+    method public androidx.xr.compose.platform.SessionCallbackProvider getDefault();
+    property public final androidx.xr.compose.platform.SessionCallbackProvider default;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SessionCallbacks {
+    method public java.io.Closeable onBoundsChanged(kotlin.jvm.functions.Function1<? super androidx.xr.scenecore.Dimensions,kotlin.Unit> callback);
+    method public java.io.Closeable onFullSpaceMode(kotlin.jvm.functions.Function0<kotlin.Unit> callback);
+    method public java.io.Closeable onHomeSpaceMode(kotlin.jvm.functions.Function1<? super androidx.xr.scenecore.Dimensions,kotlin.Unit> callback);
+    method public java.io.Closeable onSpaceModeChanged(kotlin.jvm.functions.Function1<? super androidx.xr.compose.platform.SpaceMode,kotlin.Unit> callback);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @kotlin.jvm.JvmInline public final value class SpaceMode {
+    method public int getValue();
+    property public final int value;
+    field public static final androidx.xr.compose.platform.SpaceMode.Companion Companion;
+  }
+
+  public static final class SpaceMode.Companion {
+    method public int getFull();
+    method public int getHome();
+    method public int getNotApplicable();
+    method public int getUnspecified();
+    property public int Full;
+    property public int Home;
+    property public int NotApplicable;
+    property public int Unspecified;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialCapabilities {
+    method public boolean isAppEnvironmentEnabled();
+    method public boolean isContent3dEnabled();
+    method public boolean isPassthroughControlEnabled();
+    method public boolean isSpatialAudioEnabled();
+    method public boolean isSpatialUiEnabled();
+    property public abstract boolean isAppEnvironmentEnabled;
+    property public abstract boolean isContent3dEnabled;
+    property public abstract boolean isPassthroughControlEnabled;
+    property public abstract boolean isSpatialAudioEnabled;
+    property public abstract boolean isSpatialUiEnabled;
+    field public static final androidx.xr.compose.platform.SpatialCapabilities.Companion Companion;
+  }
+
+  public static final class SpatialCapabilities.Companion {
+    method public androidx.xr.compose.platform.SpatialCapabilities getOrCreate(androidx.xr.scenecore.Session session);
+  }
+
+  public final class SpatialCapabilitiesKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.compose.runtime.CompositionLocal<androidx.xr.compose.platform.SpatialCapabilities> getLocalSpatialCapabilities();
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final androidx.compose.runtime.CompositionLocal<androidx.xr.compose.platform.SpatialCapabilities> LocalSpatialCapabilities;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialConfiguration {
+    method public default androidx.xr.compose.unit.DpVolumeSize getBounds();
+    method public default boolean hasXrSpatialFeature();
+    method public default void requestFullSpaceMode();
+    method public default void requestHomeSpaceMode();
+    property public default androidx.xr.compose.unit.DpVolumeSize bounds;
+    property public default boolean hasXrSpatialFeature;
+    field public static final androidx.xr.compose.platform.SpatialConfiguration.Companion Companion;
+  }
+
+  public static final class SpatialConfiguration.Companion {
+    method public androidx.xr.compose.platform.SpatialConfiguration getOrCreate(androidx.xr.scenecore.Session session, boolean hasXrSpatialFeature);
+    method public boolean hasXrSpatialFeature(android.content.Context context);
+  }
+
+  public final class SpatialConfigurationKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.compose.runtime.ProvidableCompositionLocal<java.lang.Boolean> getLocalHasXrSpatialFeature();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.compose.runtime.CompositionLocal<androidx.xr.compose.platform.SpatialConfiguration> getLocalSpatialConfiguration();
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final androidx.compose.runtime.ProvidableCompositionLocal<java.lang.Boolean> LocalHasXrSpatialFeature;
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final androidx.compose.runtime.CompositionLocal<androidx.xr.compose.platform.SpatialConfiguration> LocalSpatialConfiguration;
+  }
+
+}
+
+package androidx.xr.compose.spatial {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EdgeOffset {
+    method public androidx.xr.compose.spatial.EdgeOffset copy(optional float amount, optional int type);
+    method public float getAmount();
+    method public int getType();
+    property public final float amount;
+    property public int type;
+    field public static final androidx.xr.compose.spatial.EdgeOffset.Companion Companion;
+  }
+
+  public static final class EdgeOffset.Companion {
+    method @androidx.compose.runtime.Composable public androidx.xr.compose.spatial.EdgeOffset inner(float offset);
+    method @androidx.compose.runtime.Composable public androidx.xr.compose.spatial.EdgeOffset outer(float offset);
+    method @androidx.compose.runtime.Composable public androidx.xr.compose.spatial.EdgeOffset overlap(float offset);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OrbiterDefaults {
+    method public androidx.xr.compose.spatial.OrbiterSettings getOrbiterSettings();
+    method public androidx.xr.compose.subspace.layout.SpatialShape getShape();
+    property public final androidx.xr.compose.spatial.OrbiterSettings orbiterSettings;
+    property public final androidx.xr.compose.subspace.layout.SpatialShape shape;
+    field public static final androidx.xr.compose.spatial.OrbiterDefaults INSTANCE;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public sealed interface OrbiterEdge {
+    field public static final androidx.xr.compose.spatial.OrbiterEdge.Companion Companion;
+  }
+
+  public static final class OrbiterEdge.Companion {
+    method public int getBottom();
+    method public int getEnd();
+    method public int getStart();
+    method public int getTop();
+    property public int Bottom;
+    property public int End;
+    property public int Start;
+    property public int Top;
+  }
+
+  @kotlin.jvm.JvmInline public static final value class OrbiterEdge.Horizontal implements androidx.xr.compose.spatial.OrbiterEdge {
+    field public static final androidx.xr.compose.spatial.OrbiterEdge.Horizontal.Companion Companion;
+  }
+
+  public static final class OrbiterEdge.Horizontal.Companion {
+    method public int getBottom();
+    method public int getTop();
+    property public int Bottom;
+    property public int Top;
+  }
+
+  @kotlin.jvm.JvmInline public static final value class OrbiterEdge.Vertical implements androidx.xr.compose.spatial.OrbiterEdge {
+    field public static final androidx.xr.compose.spatial.OrbiterEdge.Vertical.Companion Companion;
+  }
+
+  public static final class OrbiterEdge.Vertical.Companion {
+    method public int getEnd();
+    method public int getStart();
+    property public int End;
+    property public int Start;
+  }
+
+  public final class OrbiterKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void Orbiter(int position, androidx.xr.compose.spatial.EdgeOffset offset, optional androidx.compose.ui.Alignment.Horizontal alignment, optional androidx.xr.compose.spatial.OrbiterSettings settings, optional androidx.xr.compose.subspace.layout.SpatialShape shape, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void Orbiter(int position, androidx.xr.compose.spatial.EdgeOffset offset, optional androidx.compose.ui.Alignment.Vertical alignment, optional androidx.xr.compose.spatial.OrbiterSettings settings, optional androidx.xr.compose.subspace.layout.SpatialShape shape, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void Orbiter(int position, optional float offset, optional androidx.compose.ui.Alignment.Horizontal alignment, optional androidx.xr.compose.spatial.OrbiterSettings settings, optional androidx.xr.compose.subspace.layout.SpatialShape shape, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void Orbiter(int position, optional float offset, optional androidx.compose.ui.Alignment.Vertical alignment, optional androidx.xr.compose.spatial.OrbiterSettings settings, optional androidx.xr.compose.subspace.layout.SpatialShape shape, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @kotlin.jvm.JvmInline public final value class OrbiterOffsetType {
+    field public static final androidx.xr.compose.spatial.OrbiterOffsetType.Companion Companion;
+  }
+
+  public static final class OrbiterOffsetType.Companion {
+    method public int getInnerEdge();
+    method public int getOuterEdge();
+    property public int InnerEdge;
+    property public int OuterEdge;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OrbiterSettings {
+    ctor public OrbiterSettings();
+    ctor public OrbiterSettings(optional boolean shouldRenderInNonSpatial);
+    method public androidx.xr.compose.spatial.OrbiterSettings copy(optional boolean shouldRenderInNonSpatial);
+    method public boolean shouldRenderInNonSpatial();
+    property public final boolean shouldRenderInNonSpatial;
+  }
+
+  public final class SpatialDialogKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void SpatialDialog(kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, optional androidx.xr.compose.spatial.SpatialDialogProperties properties, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialDialogProperties {
+    ctor public SpatialDialogProperties();
+    ctor public SpatialDialogProperties(optional boolean dismissOnBackPress, optional boolean dismissOnClickOutside, optional boolean usePlatformDefaultWidth, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> restingLevelAnimationSpec, optional float spatialElevationLevel);
+    method public androidx.xr.compose.spatial.SpatialDialogProperties copy(optional boolean dismissOnBackPress, optional boolean dismissOnClickOutside, optional boolean usePlatformDefaultWidth, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> restingLevelAnimationSpec, optional float spatialElevationLevel);
+    method public boolean getDismissOnBackPress();
+    method public boolean getDismissOnClickOutside();
+    method public androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> getRestingLevelAnimationSpec();
+    method public float getSpatialElevationLevel();
+    method public boolean getUsePlatformDefaultWidth();
+    property public final boolean dismissOnBackPress;
+    property public final boolean dismissOnClickOutside;
+    property public final androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> restingLevelAnimationSpec;
+    property public float spatialElevationLevel;
+    property public final boolean usePlatformDefaultWidth;
+  }
+
+  public final class SpatialElevationKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void SpatialElevation(optional float spatialElevationLevel, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @kotlin.jvm.JvmInline public final value class SpatialElevationLevel {
+    method public float getLevel();
+    property public final float level;
+    field public static final androidx.xr.compose.spatial.SpatialElevationLevel.Companion Companion;
+  }
+
+  public static final class SpatialElevationLevel.Companion {
+    method public float getLevel0();
+    method public float getLevel1();
+    method public float getLevel2();
+    method public float getLevel3();
+    method public float getLevel4();
+    method public float getLevel5();
+    property public float Level0;
+    property public float Level1;
+    property public float Level2;
+    property public float Level3;
+    property public float Level4;
+    property public float Level5;
+  }
+
+  public final class SpatialPopupKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void SpatialPopup(optional androidx.compose.ui.Alignment alignment, optional long offset, optional kotlin.jvm.functions.Function0<kotlin.Unit>? onDismissRequest, optional androidx.xr.compose.spatial.SpatialPopupProperties properties, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void SpatialPopup(optional float spatialElevationLevel, kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function1<? super androidx.compose.ui.layout.LayoutCoordinates,kotlin.Unit>,kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialPopupProperties {
+    ctor public SpatialPopupProperties();
+    ctor public SpatialPopupProperties(optional boolean focusable, optional boolean dismissOnBackPress, optional boolean dismissOnClickOutside, optional boolean clippingEnabled, optional float spatialElevationLevel);
+    method public androidx.xr.compose.spatial.SpatialPopupProperties copy(optional boolean focusable, optional boolean dismissOnBackPress, optional boolean dismissOnClickOutside, optional boolean clippingEnabled, optional float spatialElevationLevel);
+    method public boolean getClippingEnabled();
+    method public boolean getDismissOnBackPress();
+    method public boolean getDismissOnClickOutside();
+    method public boolean getFocusable();
+    method public float getSpatialElevationLevel();
+    property public final boolean clippingEnabled;
+    property public final boolean dismissOnBackPress;
+    property public final boolean dismissOnClickOutside;
+    property public final boolean focusable;
+    property public float spatialElevationLevel;
+  }
+
+  public final class SubspaceKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static void Subspace(kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.SpatialBoxScope,kotlin.Unit> content);
+  }
+
+}
+
+package androidx.xr.compose.subspace {
+
+  public final class RememberComposeViewKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable public static androidx.compose.ui.platform.ComposeView rememberComposeView(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  public final class SpatialBoxKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional boolean propagateMinConstraints, optional String name, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.SpatialBoxScope,kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.foundation.layout.LayoutScopeMarker public interface SpatialBoxScope {
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment alignment);
+  }
+
+  public final class SpatialColumnKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialColumn(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional String name, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.SpatialColumnScope,kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.foundation.layout.LayoutScopeMarker public interface SpatialColumnScope {
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Depth alignment);
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Horizontal alignment);
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier weight(androidx.xr.compose.subspace.layout.SubspaceModifier, @FloatRange(from=0.0, fromInclusive=false) float weight, optional boolean fill);
+  }
+
+  public final class SpatialLayoutSpacerKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialLayoutSpacer(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialLayoutSpacer(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialPanelDefaults {
+    method public androidx.xr.compose.subspace.layout.SpatialShape getShape();
+    property public final androidx.xr.compose.subspace.layout.SpatialShape shape;
+    field public static final androidx.xr.compose.subspace.SpatialPanelDefaults INSTANCE;
+  }
+
+  public final class SpatialPanelKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void MainPanel(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialShape shape);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialPanel(android.content.Intent intent, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, optional androidx.xr.compose.subspace.layout.SpatialShape shape);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialPanel(android.view.View view, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, optional androidx.xr.compose.subspace.layout.SpatialShape shape);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialPanel(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, optional androidx.xr.compose.subspace.layout.SpatialShape shape, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  public final class SpatialRowKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialRow(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional float curveRadius, optional String name, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.SpatialRowScope,kotlin.Unit> content);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.foundation.layout.LayoutScopeMarker public interface SpatialRowScope {
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Depth alignment);
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Vertical alignment);
+    method public androidx.xr.compose.subspace.layout.SubspaceModifier weight(androidx.xr.compose.subspace.layout.SubspaceModifier, @FloatRange(from=0.0, fromInclusive=false) float weight, optional boolean fill);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.ComposableTargetMarker(description="Subspace Composable") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FILE, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER, kotlin.annotation.AnnotationTarget.TYPE, kotlin.annotation.AnnotationTarget.TYPE_PARAMETER}) public @interface SubspaceComposable {
+  }
+
+  public final class VolumeKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void Volume(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, kotlin.jvm.functions.Function1<? super androidx.xr.scenecore.Entity,kotlin.Unit> onVolumeEntity);
+  }
+
+}
+
+package androidx.xr.compose.subspace.layout {
+
+  public final class AlphaKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier alpha(androidx.xr.compose.subspace.layout.SubspaceModifier, @FloatRange(from=0.0, to=1.0) float alpha);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class CombinedSubspaceModifier implements androidx.xr.compose.subspace.layout.SubspaceModifier {
+    ctor public CombinedSubspaceModifier(androidx.xr.compose.subspace.layout.SubspaceModifier outer, androidx.xr.compose.subspace.layout.SubspaceModifier inner);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Measurable {
+    method public void adjustParams(androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable params);
+    method public androidx.xr.compose.subspace.layout.Placeable measure(androidx.xr.compose.unit.VolumeConstraints constraints);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface MeasurePolicy {
+    method public androidx.xr.compose.subspace.layout.MeasureResult measure(androidx.xr.compose.subspace.layout.MeasureScope, java.util.List<? extends androidx.xr.compose.subspace.layout.Measurable> measurables, androidx.xr.compose.unit.VolumeConstraints constraints);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface MeasureResult {
+    method public int getDepth();
+    method public int getHeight();
+    method public int getWidth();
+    method public void placeChildren(androidx.xr.compose.subspace.layout.Placeable.PlacementScope placementScope);
+    property public abstract int depth;
+    property public abstract int height;
+    property public abstract int width;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface MeasureScope extends androidx.compose.ui.unit.Density {
+    method public default float getDensity();
+    method public default float getFontScale();
+    method public default androidx.xr.compose.subspace.layout.MeasureResult layout(int width, int height, int depth, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.layout.Placeable.PlacementScope,kotlin.Unit> placementBlock);
+    property public default float density;
+    property public default float fontScale;
+  }
+
+  public final class MovableKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier movable(androidx.xr.compose.subspace.layout.SubspaceModifier, optional boolean enabled, optional boolean stickyPose, optional kotlin.jvm.functions.Function1<? super androidx.xr.runtime.math.Pose,java.lang.Boolean> onPoseChange);
+  }
+
+  public final class OffsetKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier offset(androidx.xr.compose.subspace.layout.SubspaceModifier, optional float x, optional float y, optional float z);
+  }
+
+  public final class OnGloballyPositionedModifierKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier onGloballyPositioned(androidx.xr.compose.subspace.layout.SubspaceModifier, kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates,kotlin.Unit> onGloballyPositioned);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OnGloballyPositionedNode extends androidx.xr.compose.subspace.layout.SubspaceModifier.Node {
+    ctor public OnGloballyPositionedNode(kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates,kotlin.Unit> callback);
+    method public kotlin.jvm.functions.Function1<androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates,kotlin.Unit> getCallback();
+    method public void setCallback(kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates,kotlin.Unit>);
+    property public final kotlin.jvm.functions.Function1<androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates,kotlin.Unit> callback;
+  }
+
+  public final class PaddingKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, float all);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional float horizontal, optional float vertical, optional float depth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier padding(androidx.xr.compose.subspace.layout.SubspaceModifier, optional float left, optional float top, optional float right, optional float bottom, optional float front, optional float back);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ParentLayoutParamsAdjustable {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ParentLayoutParamsModifier {
+    method public void adjustParams(androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable params);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class Placeable {
+    ctor public Placeable();
+    method public final int getMeasuredDepth();
+    method public final int getMeasuredHeight();
+    method public final int getMeasuredWidth();
+    method protected abstract void placeAt(androidx.xr.runtime.math.Pose pose);
+    method public final void setMeasuredDepth(int);
+    method public final void setMeasuredHeight(int);
+    method public final void setMeasuredWidth(int);
+    property public final int measuredDepth;
+    property public final int measuredHeight;
+    property public final int measuredWidth;
+  }
+
+  public abstract static class Placeable.PlacementScope {
+    ctor public Placeable.PlacementScope();
+    method public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? getCoordinates();
+    method public final void place(androidx.xr.compose.subspace.layout.Placeable, androidx.xr.runtime.math.Pose pose);
+    property public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? coordinates;
+  }
+
+  public final class ResizableKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier resizable(androidx.xr.compose.subspace.layout.SubspaceModifier, optional boolean enabled, optional androidx.xr.compose.unit.DpVolumeSize minimumSize, optional androidx.xr.compose.unit.DpVolumeSize maximumSize, optional boolean maintainAspectRatio, optional kotlin.jvm.functions.Function1<? super androidx.xr.compose.unit.IntVolumeSize,java.lang.Boolean> onSizeChange);
+  }
+
+  public final class RotateKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier rotate(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.runtime.math.Quaternion quaternion);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier rotate(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.runtime.math.Vector3 axisAngle, float rotation);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier rotate(androidx.xr.compose.subspace.layout.SubspaceModifier, float pitch, float yaw, float roll);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class RotationNode extends androidx.xr.compose.subspace.layout.SubspaceModifier.Node implements androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode {
+    ctor public RotationNode(androidx.xr.runtime.math.Quaternion quaternion);
+    method public androidx.xr.runtime.math.Quaternion getQuaternion();
+    method public androidx.xr.compose.subspace.layout.MeasureResult measure(androidx.xr.compose.subspace.layout.MeasureScope, androidx.xr.compose.subspace.layout.Measurable measurable, androidx.xr.compose.unit.VolumeConstraints constraints);
+    method public void setQuaternion(androidx.xr.runtime.math.Quaternion);
+    property public final androidx.xr.runtime.math.Quaternion quaternion;
+  }
+
+  public final class ScaleKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier scale(androidx.xr.compose.subspace.layout.SubspaceModifier, float scale);
+  }
+
+  public final class SemanticsModifierKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier semantics(androidx.xr.compose.subspace.layout.SubspaceModifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.semantics.SemanticsPropertyReceiver,kotlin.Unit> properties);
+  }
+
+  public final class SizeKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier depth(androidx.xr.compose.subspace.layout.SubspaceModifier, float depth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier fillMaxDepth(androidx.xr.compose.subspace.layout.SubspaceModifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier fillMaxHeight(androidx.xr.compose.subspace.layout.SubspaceModifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier fillMaxSize(androidx.xr.compose.subspace.layout.SubspaceModifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier fillMaxWidth(androidx.xr.compose.subspace.layout.SubspaceModifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier height(androidx.xr.compose.subspace.layout.SubspaceModifier, float height);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier requiredDepth(androidx.xr.compose.subspace.layout.SubspaceModifier, float depth);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier requiredHeight(androidx.xr.compose.subspace.layout.SubspaceModifier, float height);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier requiredSize(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.unit.DpVolumeSize size);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier requiredSize(androidx.xr.compose.subspace.layout.SubspaceModifier, float size);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier requiredWidth(androidx.xr.compose.subspace.layout.SubspaceModifier, float width);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier size(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.unit.DpVolumeSize size);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier size(androidx.xr.compose.subspace.layout.SubspaceModifier, float size);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier width(androidx.xr.compose.subspace.layout.SubspaceModifier, float width);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialAlignment {
+    method public int depthOffset(int depth, int space);
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getBack();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getBottom();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getBottomCenter();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getBottomLeft();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getBottomRight();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getCenter();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getCenterDepthwise();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getCenterHorizontally();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getCenterLeft();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getCenterRight();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getCenterVertically();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getFront();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getLeft();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getRight();
+    method public static androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getTop();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getTopCenter();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getTopLeft();
+    method public static androidx.xr.compose.subspace.layout.SpatialAlignment getTopRight();
+    method public int horizontalOffset(int width, int space);
+    method public androidx.xr.runtime.math.Vector3 position(androidx.xr.compose.unit.IntVolumeSize size, androidx.xr.compose.unit.IntVolumeSize space);
+    method public int verticalOffset(int height, int space);
+    field public static final androidx.xr.compose.subspace.layout.SpatialAlignment.Companion Companion;
+  }
+
+  public static final class SpatialAlignment.Companion {
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getBack();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getBottom();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getBottomCenter();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getBottomLeft();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getBottomRight();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getCenter();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getCenterDepthwise();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getCenterHorizontally();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getCenterLeft();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getCenterRight();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getCenterVertically();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth getFront();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getLeft();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal getRight();
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical getTop();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getTopCenter();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getTopLeft();
+    method public androidx.xr.compose.subspace.layout.SpatialAlignment getTopRight();
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth Back;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical Bottom;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment BottomCenter;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment BottomLeft;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment BottomRight;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment Center;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth CenterDepthwise;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal CenterHorizontally;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment CenterLeft;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment CenterRight;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical CenterVertically;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth Front;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal Left;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal Right;
+    property public final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical Top;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment TopCenter;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment TopLeft;
+    property public final androidx.xr.compose.subspace.layout.SpatialAlignment TopRight;
+  }
+
+  public static interface SpatialAlignment.Depth {
+    method public int offset(int depth, int space);
+  }
+
+  public static interface SpatialAlignment.Horizontal {
+    method public int offset(int width, int space);
+  }
+
+  public static interface SpatialAlignment.Vertical {
+    method public int offset(int height, int space);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialBiasAlignment implements androidx.xr.compose.subspace.layout.SpatialAlignment {
+    ctor public SpatialBiasAlignment(float horizontalBias, float verticalBias, float depthBias);
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment copy(optional float horizontalBias, optional float verticalBias, optional float depthBias);
+    method public int depthOffset(int depth, int space);
+    method public float getDepthBias();
+    method public float getHorizontalBias();
+    method public float getVerticalBias();
+    method public int horizontalOffset(int width, int space);
+    method public androidx.xr.runtime.math.Vector3 position(androidx.xr.compose.unit.IntVolumeSize size, androidx.xr.compose.unit.IntVolumeSize space);
+    method public int verticalOffset(int height, int space);
+    property public final float depthBias;
+    property public final float horizontalBias;
+    property public final float verticalBias;
+    field public static final androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Companion Companion;
+  }
+
+  public static final class SpatialBiasAlignment.Companion {
+  }
+
+  public static final class SpatialBiasAlignment.Depth implements androidx.xr.compose.subspace.layout.SpatialAlignment.Depth {
+    ctor public SpatialBiasAlignment.Depth(float bias);
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Depth copy(optional float bias);
+    method public float getBias();
+    method public int offset(int depth, int space);
+    property public final float bias;
+  }
+
+  public static final class SpatialBiasAlignment.Horizontal implements androidx.xr.compose.subspace.layout.SpatialAlignment.Horizontal {
+    ctor public SpatialBiasAlignment.Horizontal(float bias);
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Horizontal copy(optional float bias);
+    method public float getBias();
+    method public int offset(int width, int space);
+    property public final float bias;
+  }
+
+  public static final class SpatialBiasAlignment.Vertical implements androidx.xr.compose.subspace.layout.SpatialAlignment.Vertical {
+    ctor public SpatialBiasAlignment.Vertical(float bias);
+    method public androidx.xr.compose.subspace.layout.SpatialBiasAlignment.Vertical copy(optional float bias);
+    method public float getBias();
+    method public int offset(int height, int space);
+    property public final float bias;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialRoundedCornerShape extends androidx.xr.compose.subspace.layout.SpatialShape {
+    ctor public SpatialRoundedCornerShape(androidx.compose.foundation.shape.CornerSize size);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class SpatialShape {
+    ctor public SpatialShape();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SubspaceLayoutCoordinates {
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.math.Pose getPoseInParentEntity();
+    method public androidx.xr.runtime.math.Pose getPoseInRoot();
+    method @Deprecated public default androidx.xr.runtime.math.Vector3 getPosition();
+    method @Deprecated public default androidx.xr.runtime.math.Vector3 getPositionInParentEntity();
+    method @Deprecated public default androidx.xr.runtime.math.Vector3 getPositionInRoot();
+    method @Deprecated public default androidx.xr.runtime.math.Quaternion getRotation();
+    method @Deprecated public default androidx.xr.runtime.math.Quaternion getRotationInParentEntity();
+    method @Deprecated public default androidx.xr.runtime.math.Quaternion getRotationInRoot();
+    method public androidx.xr.compose.unit.IntVolumeSize getSize();
+    property public abstract androidx.xr.runtime.math.Pose pose;
+    property public abstract androidx.xr.runtime.math.Pose poseInParentEntity;
+    property public abstract androidx.xr.runtime.math.Pose poseInRoot;
+    property @Deprecated public default androidx.xr.runtime.math.Vector3 position;
+    property @Deprecated public default androidx.xr.runtime.math.Vector3 positionInParentEntity;
+    property @Deprecated public default androidx.xr.runtime.math.Vector3 positionInRoot;
+    property @Deprecated public default androidx.xr.runtime.math.Quaternion rotation;
+    property @Deprecated public default androidx.xr.runtime.math.Quaternion rotationInParentEntity;
+    property @Deprecated public default androidx.xr.runtime.math.Quaternion rotationInRoot;
+    property public abstract androidx.xr.compose.unit.IntVolumeSize size;
+  }
+
+  public final class SubspaceLayoutCoordinatesKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static String toDebugString(androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates);
+  }
+
+  public final class SubspaceLayoutKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, androidx.xr.compose.subspace.layout.MeasurePolicy measurePolicy);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(kotlin.jvm.functions.Function0<kotlin.Unit> content, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional String name, androidx.xr.compose.subspace.layout.MeasurePolicy measurePolicy);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static String defaultSubspaceLayoutName();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SubspaceModifier {
+    method public default boolean all(kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.node.SubspaceModifierElement<androidx.xr.compose.subspace.layout.SubspaceModifier.Node>,java.lang.Boolean> predicate);
+    method public default boolean any(kotlin.jvm.functions.Function1<? super androidx.xr.compose.subspace.node.SubspaceModifierElement<androidx.xr.compose.subspace.layout.SubspaceModifier.Node>,java.lang.Boolean> predicate);
+    method public default <R> R foldIn(R initial, kotlin.jvm.functions.Function2<? super R,? super androidx.xr.compose.subspace.node.SubspaceModifierElement<androidx.xr.compose.subspace.layout.SubspaceModifier.Node>,? extends R> operation);
+    method public default <R> R foldOut(R initial, kotlin.jvm.functions.Function2<? super androidx.xr.compose.subspace.node.SubspaceModifierElement<androidx.xr.compose.subspace.layout.SubspaceModifier.Node>,? super R,? extends R> operation);
+    method public default infix androidx.xr.compose.subspace.layout.SubspaceModifier then(androidx.xr.compose.subspace.layout.SubspaceModifier other);
+    field public static final androidx.xr.compose.subspace.layout.SubspaceModifier.Companion Companion;
+  }
+
+  public static final class SubspaceModifier.Companion implements androidx.xr.compose.subspace.layout.SubspaceModifier {
+    method public infix androidx.xr.compose.subspace.layout.SubspaceModifier then(androidx.xr.compose.subspace.node.SubspaceModifierElement<androidx.xr.compose.subspace.layout.SubspaceModifier.Node> other);
+  }
+
+  public abstract static class SubspaceModifier.Node {
+    ctor public SubspaceModifier.Node();
+  }
+
+  public final class SubspaceModifierKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static kotlin.sequences.Sequence<androidx.xr.compose.subspace.layout.SubspaceModifier.Node> traverseAncestors(androidx.xr.compose.subspace.layout.SubspaceModifier.Node);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static kotlin.sequences.Sequence<androidx.xr.compose.subspace.layout.SubspaceModifier.Node> traverseDescendants(androidx.xr.compose.subspace.layout.SubspaceModifier.Node);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static kotlin.sequences.Sequence<androidx.xr.compose.subspace.layout.SubspaceModifier.Node> traverseSelfThenAncestors(androidx.xr.compose.subspace.layout.SubspaceModifier.Node);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static kotlin.sequences.Sequence<androidx.xr.compose.subspace.layout.SubspaceModifier.Node> traverseSelfThenDescendants(androidx.xr.compose.subspace.layout.SubspaceModifier.Node);
+  }
+
+  public final class TestTagKt {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.compose.subspace.layout.SubspaceModifier testTag(androidx.xr.compose.subspace.layout.SubspaceModifier, String tag);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class TestTagNode extends androidx.xr.compose.subspace.layout.SubspaceModifier.Node implements androidx.xr.compose.subspace.node.SubspaceSemanticsModifierNode {
+    ctor public TestTagNode(String tag);
+    method public void applySemantics(androidx.compose.ui.semantics.SemanticsPropertyReceiver);
+    method public String getTag();
+    method public void setTag(String);
+    property public final String tag;
+  }
+
+}
+
+package androidx.xr.compose.subspace.node {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SubspaceLayoutModifierNode {
+    method public androidx.xr.compose.subspace.layout.MeasureResult measure(androidx.xr.compose.subspace.layout.MeasureScope, androidx.xr.compose.subspace.layout.Measurable measurable, androidx.xr.compose.unit.VolumeConstraints constraints);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class SubspaceModifierElement<N extends androidx.xr.compose.subspace.layout.SubspaceModifier.Node> implements androidx.xr.compose.subspace.layout.SubspaceModifier {
+    ctor public SubspaceModifierElement();
+    method public abstract N create();
+    method public abstract boolean equals(Object? other);
+    method public abstract int hashCode();
+    method public abstract void update(N node);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SubspaceSemanticsModifierNode {
+    method public void applySemantics(androidx.compose.ui.semantics.SemanticsPropertyReceiver);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SubspaceSemanticsNode {
+    method public java.util.List<androidx.xr.compose.subspace.node.SubspaceSemanticsNode> getChildren();
+    method public java.util.List<androidx.xr.scenecore.Component>? getComponents();
+    method public androidx.compose.ui.semantics.SemanticsConfiguration getConfig();
+    method public int getId();
+    method public androidx.xr.compose.subspace.node.SubspaceSemanticsNode? getParent();
+    method public androidx.xr.runtime.math.Vector3 getPosition();
+    method public androidx.xr.runtime.math.Vector3 getPositionInRoot();
+    method public androidx.xr.runtime.math.Quaternion getRotation();
+    method public androidx.xr.runtime.math.Quaternion getRotationInRoot();
+    method public float getScale();
+    method public androidx.xr.compose.unit.IntVolumeSize getSize();
+    method public boolean isRoot();
+    property public final java.util.List<androidx.xr.compose.subspace.node.SubspaceSemanticsNode> children;
+    property public final java.util.List<androidx.xr.scenecore.Component>? components;
+    property public final androidx.compose.ui.semantics.SemanticsConfiguration config;
+    property public final int id;
+    property public final boolean isRoot;
+    property public final androidx.xr.compose.subspace.node.SubspaceSemanticsNode? parent;
+    property public final androidx.xr.runtime.math.Vector3 position;
+    property public final androidx.xr.runtime.math.Vector3 positionInRoot;
+    property public final androidx.xr.runtime.math.Quaternion rotation;
+    property public final androidx.xr.runtime.math.Quaternion rotationInRoot;
+    property public final float scale;
+    property public final androidx.xr.compose.unit.IntVolumeSize size;
+  }
+
+}
+
+package androidx.xr.compose.unit {
+
+  public final class DpVolumeSize {
+    ctor public DpVolumeSize(float width, float height, float depth);
+    method public float getDepth();
+    method public float getHeight();
+    method public float getWidth();
+    property public float depth;
+    property public float height;
+    property public float width;
+    field public static final androidx.xr.compose.unit.DpVolumeSize.Companion Companion;
+  }
+
+  public static final class DpVolumeSize.Companion {
+    method public androidx.xr.compose.unit.DpVolumeSize getZero();
+    property public final androidx.xr.compose.unit.DpVolumeSize Zero;
+  }
+
+  public final class IntVolumeSize {
+    ctor public IntVolumeSize(int width, int height, int depth);
+    method public int getDepth();
+    method public int getHeight();
+    method public int getWidth();
+    property public final int depth;
+    property public final int height;
+    property public final int width;
+    field public static final androidx.xr.compose.unit.IntVolumeSize.Companion Companion;
+  }
+
+  public static final class IntVolumeSize.Companion {
+    method public androidx.xr.compose.unit.IntVolumeSize getZero();
+    property public final androidx.xr.compose.unit.IntVolumeSize Zero;
+  }
+
+  @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class Meter implements java.lang.Comparable<androidx.xr.compose.unit.Meter> {
+    ctor public Meter(float value);
+    method public int compareTo(float other);
+    method public inline operator float div(double other);
+    method public inline operator float div(float other);
+    method public inline operator float div(int other);
+    method public float getValue();
+    method public inline operator float minus(float other);
+    method public inline operator float plus(float other);
+    method public inline int roundToPx(androidx.compose.ui.unit.Density density);
+    method public inline operator float times(double other);
+    method public inline operator float times(float other);
+    method public inline operator float times(int other);
+    method public inline float toCm();
+    method public inline float toDp();
+    method public inline float toM();
+    method public inline float toMm();
+    method public inline float toPx(androidx.compose.ui.unit.Density density);
+    property public final inline boolean isFinite;
+    property public final inline boolean isSpecified;
+    property public final float value;
+    field public static final androidx.xr.compose.unit.Meter.Companion Companion;
+    field @kotlin.PublishedApi internal static final float DP_PER_METER;
+  }
+
+  public static final class Meter.Companion {
+    method public inline float fromPixel(float px, androidx.compose.ui.unit.Density density);
+    method public float getCentimeters();
+    method public float getCentimeters();
+    method public float getCentimeters();
+    method public float getInfinity();
+    method public float getMeters();
+    method public float getMeters();
+    method public float getMeters();
+    method public float getMillimeters();
+    method public float getMillimeters();
+    method public float getMillimeters();
+    method public float getNaN();
+    property @kotlin.PublishedApi internal final float DP_PER_METER;
+    property public float Infinity;
+    property public float NaN;
+    property public float centimeters;
+    property public float centimeters;
+    property public float centimeters;
+    property public float meters;
+    property public float meters;
+    property public float meters;
+    property public float millimeters;
+    property public float millimeters;
+    property public float millimeters;
+  }
+
+  public final class MeterKt {
+    method public static inline operator float div(double, float other);
+    method public static inline operator float div(float, float other);
+    method public static inline operator float div(int, float other);
+    method public static inline operator float times(double, float other);
+    method public static inline operator float times(float, float other);
+    method public static inline operator float times(int, float other);
+    method public static inline float toMeter(float);
+  }
+
+  public final class VolumeConstraints {
+    ctor public VolumeConstraints(int minWidth, int maxWidth, int minHeight, int maxHeight, optional int minDepth, optional int maxDepth);
+    method public androidx.xr.compose.unit.VolumeConstraints copy(optional int minWidth, optional int maxWidth, optional int minHeight, optional int maxHeight, optional int minDepth, optional int maxDepth);
+    method public int getMaxDepth();
+    method public int getMaxHeight();
+    method public int getMaxWidth();
+    method public int getMinDepth();
+    method public int getMinHeight();
+    method public int getMinWidth();
+    method public boolean hasBoundedDepth();
+    method public boolean hasBoundedHeight();
+    method public boolean hasBoundedWidth();
+    property public final boolean hasBoundedDepth;
+    property public final boolean hasBoundedHeight;
+    property public final boolean hasBoundedWidth;
+    property public final int maxDepth;
+    property public final int maxHeight;
+    property public final int maxWidth;
+    property public final int minDepth;
+    property public final int minHeight;
+    property public final int minWidth;
+    field public static final androidx.xr.compose.unit.VolumeConstraints.Companion Companion;
+    field public static final int INFINITY = 2147483647; // 0x7fffffff
+  }
+
+  public static final class VolumeConstraints.Companion {
+    property public static final int INFINITY;
+  }
+
+  public final class VolumeConstraintsKt {
+    method public static androidx.xr.compose.unit.VolumeConstraints constrain(androidx.xr.compose.unit.VolumeConstraints, androidx.xr.compose.unit.VolumeConstraints otherConstraints);
+    method public static int constrainDepth(androidx.xr.compose.unit.VolumeConstraints, int depth);
+    method public static int constrainHeight(androidx.xr.compose.unit.VolumeConstraints, int height);
+    method public static int constrainWidth(androidx.xr.compose.unit.VolumeConstraints, int width);
+    method public static androidx.xr.compose.unit.VolumeConstraints offset(androidx.xr.compose.unit.VolumeConstraints, optional int horizontal, optional int vertical, optional int depth, optional boolean resetMins);
+  }
+
+}
+
diff --git a/xr/compose/compose/build.gradle b/xr/compose/compose/build.gradle
new file mode 100644
index 0000000..b134495
--- /dev/null
+++ b/xr/compose/compose/build.gradle
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.KotlinTarget
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+    id("AndroidXComposePlugin")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation("androidx.savedstate:savedstate:1.2.1")
+    implementation("androidx.activity:activity:1.9.3")
+    implementation("androidx.lifecycle:lifecycle-common:2.8.7")
+    implementation("androidx.lifecycle:lifecycle-runtime:2.8.7")
+    implementation(project(":xr:scenecore:scenecore"))
+
+    implementation("androidx.compose.animation:animation-core:1.7.5")
+    implementation("androidx.compose.foundation:foundation:1.7.5")
+    implementation("androidx.compose.foundation:foundation-layout:1.7.5")
+    implementation("androidx.compose.runtime:runtime:1.7.5")
+    implementation("androidx.compose.ui:ui:1.7.5")
+    implementation("androidx.compose.ui:ui-unit:1.7.5")
+
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+
+    testImplementation("androidx.compose.material3:material3:1.3.1")
+    testImplementation("androidx.compose.ui:ui-test:1.7.5")
+    testImplementation("androidx.compose.ui:ui-test-junit4:1.7.5")
+    testImplementation(project(":xr:compose:compose-testing"))
+}
+
+android {
+    defaultConfig {
+        // TODO: This should be lower, possibly 21.
+        //       Address API calls that require higher versions.
+        minSdkVersion 30
+    }
+    namespace "androidx.xr.compose"
+}
+
+androidx {
+    name = "XR Compose"
+    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
+    inceptionYear = "2024"
+    description = "Compose libraries for the androidx.xr namespace."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Activity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Activity.kt
new file mode 100644
index 0000000..a9305b5
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Activity.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.activity.ComponentActivity
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.Density
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.scenecore.Session
+
+private val activityToSpatialComposeScene = mutableMapOf<Activity, SpatialComposeScene>()
+
+/**
+ * Composes the provided composable [content] into this activity's subspace.
+ *
+ * This method only takes effect in Android XR. Calling it will cause the activity to register
+ * itself as a subspace, allowing it to control its own panel (i.e. move and resize within its
+ * subspace 3D bounds) and show additional spatial content around it.
+ *
+ * @param session A custom session to be provided for testing purposes. If null, a suitable session
+ *   for production use will be created.
+ * @param enableXrForTesting When set, this method will assume it is running on Android XR even if
+ *   it is not. Should only be used for testing, it will cause crashes on non-Android XR devices.
+ * @param content A `@Composable` function declaring the spatial UI content.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun ComponentActivity.setSubspaceContent(
+    session: Session? = null,
+    enableXrForTesting: Boolean = false,
+    content: @Composable @SubspaceComposable () -> Unit,
+) {
+    // Do nothing if we aren't running on an XR device or in an XR test.
+    if (!SpatialConfiguration.hasXrSpatialFeature(this) && !enableXrForTesting) {
+        return
+    }
+    val jxrSession = session ?: defaultSession
+    val spatialComposeScene = getOrCreateSpatialSceneForActivity(jxrSession)
+
+    spatialComposeScene.setContent {
+        DisposableEffect(jxrSession) {
+            jxrSession.mainPanelEntity.setHidden(true)
+            onDispose { jxrSession.mainPanelEntity.setHidden(false) }
+        }
+
+        // TODO(b/354009078) Why does rendering content in full space mode break presubmits.
+
+        // We need to emulate the composition locals that setContent provides
+        CompositionLocalProvider(
+            LocalConfiguration provides resources.configuration,
+            LocalDensity provides Density(this),
+            LocalView provides window.decorView,
+            content = content,
+        )
+    }
+}
+
+/**
+ * Looks up an existing [SpatialComposeScene] for this activity. If it doesn't exist, adds it to the
+ * map.
+ *
+ * @param session A custom session to be provided for testing purposes. If null, a suitable session
+ *   for production use will be created.
+ */
+private fun ComponentActivity.getOrCreateSpatialSceneForActivity(
+    session: Session = defaultSession
+): SpatialComposeScene {
+    return activityToSpatialComposeScene.computeIfAbsent(this) {
+        SpatialComposeScene(this, session).also { subspace -> setUpSubspace(subspace) }
+    }
+}
+
+/**
+ * Sets up the newly created [spatialComposeScene] to listen to [this] lifecycle state changes and
+ * to be cleaned up when [this] is destroyed.
+ */
+private fun ComponentActivity.setUpSubspace(spatialComposeScene: SpatialComposeScene) {
+    lifecycle.addObserver(spatialComposeScene)
+    lifecycle.addObserver(
+        object : LifecycleEventObserver {
+            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+                if (event == Lifecycle.Event.ON_DESTROY) {
+                    activityToSpatialComposeScene.remove(this@setUpSubspace)
+                }
+            }
+        }
+    )
+}
+
+/** Get the default [Session] for this [ComponentActivity]. */
+private val ComponentActivity.defaultSession
+    get() = Session.create(this)
+
+/** Utility extension function to fetch the current [Activity] based on the [Context] object. */
+internal tailrec fun Context.getActivity(): Activity {
+    return when (this) {
+        is Activity -> this
+        is ContextWrapper -> baseContext.getActivity()
+        else -> error("Unexpected Context type when trying to resolve the context's Activity.")
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/AndroidComposeSpatialElement.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/AndroidComposeSpatialElement.kt
new file mode 100644
index 0000000..5be8507
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/AndroidComposeSpatialElement.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.xr.compose.subspace.node.SubspaceLayoutNode
+import androidx.xr.compose.subspace.node.SubspaceOwner
+import androidx.xr.scenecore.PanelEntity
+
+/**
+ * An implementation of the [SubspaceOwner] interface, bridging the Compose layout and rendering
+ * phases.
+ *
+ * Compose decouples layout computation from rendering. This necessitates two distinct trees that
+ * [SubspaceOwner] defines and owns:
+ * 1. The layout tree: Created by [SubspaceLayoutNode], this represents the app's defined hierarchy
+ *    and is used for layout calculations.
+ * 2. The rendering tree: A flat tree rooted at the [SubspaceOwner]. This is used for rendering. The
+ *    hierarchy is necessary to ensure [SpatialElement] instances are visible on a screen (detached
+ *    [SpatialElement] instances are always hidden). The positions of [SpatialElement] are updated
+ *    post-layout computation, during the placement phase.
+ *
+ * This class draws inspiration from the [androidx/compose/ui/platform/AndroidComposeView].
+ */
+internal class AndroidComposeSpatialElement :
+    SpatialElement(), SubspaceOwner, DefaultLifecycleObserver {
+    override val root: SubspaceLayoutNode = SubspaceLayoutNode()
+
+    internal var wrappedComposition: WrappedComposition? = null
+
+    /**
+     * Callback that is registered in [setOnSubspaceAvailable] to be executed when this element is
+     * attached a subspace.
+     */
+    private var onSubspaceAvailable: ((LifecycleOwner) -> Unit)? = null
+
+    private var windowLeashLayoutNode: SubspaceLayoutNode? = null
+
+    init {
+        root.attach(this)
+    }
+
+    /**
+     * Registers the [callback] to be executed when this element is attached to a
+     * [spatialComposeScene].
+     *
+     * Note that the [callback] will be invoked immediately if [spatialComposeScene] is already
+     * available.
+     */
+    internal fun setOnSubspaceAvailable(callback: (LifecycleOwner) -> Unit) {
+        if (spatialComposeScene != null) {
+            callback(spatialComposeScene!!)
+        } else {
+            onSubspaceAvailable = callback
+        }
+    }
+
+    override fun onAttachedToSubspace(spatialComposeScene: SpatialComposeScene) {
+        super.onAttachedToSubspace(spatialComposeScene)
+
+        spatialComposeScene.lifecycle.addObserver(this)
+        onSubspaceAvailable?.invoke(spatialComposeScene)
+        onSubspaceAvailable = null
+    }
+
+    override fun onDetachedFromSubspace(spatialComposeScene: SpatialComposeScene) {
+        super.onDetachedFromSubspace(spatialComposeScene)
+
+        spatialComposeScene.lifecycle.removeObserver(this)
+    }
+
+    override fun onAttach(node: SubspaceLayoutNode) {
+        node.coreEntity?.entity.let { entity ->
+            if (entity is PanelEntity && entity.isMainPanelEntity) {
+                check(windowLeashLayoutNode == null) {
+                    "Cannot add $node as there is already another SubspaceLayoutNode for the Window Leash Node"
+                }
+                windowLeashLayoutNode = node
+            }
+        }
+    }
+
+    override fun onDetach(node: SubspaceLayoutNode) {
+        node.coreEntity?.entity.let { entity ->
+            if (entity is PanelEntity && entity.isMainPanelEntity) {
+                windowLeashLayoutNode = null
+            }
+        }
+    }
+
+    override fun onResume(owner: LifecycleOwner) {
+        super.onResume(owner)
+        // TODO: "Refresh the layout hierarchy."
+    }
+
+    override fun onDestroy(owner: LifecycleOwner) {
+        super.onDestroy(owner)
+        root.detach()
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/CompositionLocals.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/CompositionLocals.kt
new file mode 100644
index 0000000..5a0c8e1
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/CompositionLocals.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.xr.compose.subspace.SubspaceComposable
+
+@OptIn(InternalSubspaceApi::class)
+@Composable
+internal fun ProvideCompositionLocals(
+    owner: AndroidComposeSpatialElement,
+    content: @Composable @SubspaceComposable () -> Unit,
+) {
+    val subspace =
+        checkNotNull(owner.spatialComposeScene) { "Owner element must be attached to a subspace." }
+    val context = subspace.ownerActivity
+
+    CompositionLocalProvider(
+        LocalContext provides context,
+        LocalLifecycleOwner provides subspace.ownerActivity,
+        LocalSession provides subspace.jxrSession,
+        content = content,
+    )
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/DialogManager.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/DialogManager.kt
new file mode 100644
index 0000000..816f083
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/DialogManager.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/** Tracks the Elevated Status of SpatialDialog */
+internal interface DialogManager {
+    /** `true` when a SpatialDialog is triggered, `false` otherwise. */
+    public val isSpatialDialogActive: MutableState<Boolean>
+}
+
+/** Default implementation of [DialogManager]. */
+private class DefaultDialogManager : DialogManager {
+    override val isSpatialDialogActive: MutableState<Boolean> = mutableStateOf(false)
+}
+
+internal val LocalDialogManager: ProvidableCompositionLocal<DialogManager> =
+    staticCompositionLocalOf<DialogManager> { DefaultDialogManager() }
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/GlobalSnapshotManager.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/GlobalSnapshotManager.kt
new file mode 100644
index 0000000..fe8cf9e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/GlobalSnapshotManager.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.consumeEach
+import kotlinx.coroutines.launch
+
+/**
+ * Platform-specific mechanism for starting a monitor of global snapshot state writes in order to
+ * schedule the periodic dispatch of snapshot apply notifications.
+ *
+ * This process should remain platform-specific; it is tied to the threading and update model of a
+ * particular platform and framework target.
+ *
+ * Composition bootstrapping mechanisms for a particular platform/framework should call
+ * [ensureStarted] during setup to initialize periodic global snapshot notifications.
+ *
+ * For Android, these notifications are always sent on [AndroidUiDispatcher.Main]. Other platforms
+ * may establish different policies for these notifications.
+ *
+ * See [androidx.compose.ui.platform.GlobalSnapshotManager]
+ */
+internal object GlobalSnapshotManager {
+    private val started = AtomicBoolean(false)
+    private val sent = AtomicBoolean(false)
+
+    fun ensureStarted() {
+        if (started.compareAndSet(false, true)) {
+            val channel = Channel<Unit>(1)
+            CoroutineScope(AndroidUiDispatcher.Main).launch {
+                channel.consumeEach {
+                    sent.set(false)
+                    Snapshot.sendApplyNotifications()
+                }
+            }
+            Snapshot.registerGlobalWriteObserver {
+                if (sent.compareAndSet(false, true)) {
+                    @Suppress("UNUSED_VARIABLE") val unused = channel.trySend(Unit)
+                }
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/InternalSubspaceApi.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/InternalSubspaceApi.kt
new file mode 100644
index 0000000..96d3df0
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/InternalSubspaceApi.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+@RequiresOptIn(level = RequiresOptIn.Level.ERROR, message = "This API is internal to the library.")
+@Target(
+    AnnotationTarget.CLASS,
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY,
+    AnnotationTarget.FIELD,
+    AnnotationTarget.PROPERTY_GETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+internal annotation class InternalSubspaceApi
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalPanelEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalPanelEntity.kt
new file mode 100644
index 0000000..d05da44
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalPanelEntity.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalOf
+import androidx.xr.scenecore.PanelEntity
+
+/**
+ * A CompositionLocal that holds the current [PanelEntity] acting as the parent panel for any
+ * containing composed UI.
+ */
+internal val LocalPanelEntity: ProvidableCompositionLocal<PanelEntity?> =
+    compositionLocalOf<PanelEntity?> { null }
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalSession.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalSession.kt
new file mode 100644
index 0000000..4310a8e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/LocalSession.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalWithComputedDefaultOf
+import androidx.compose.ui.platform.LocalContext
+import androidx.xr.scenecore.Session
+
+/**
+ * A composition local that provides the current Jetpack XR [Session].
+ *
+ * In non-XR environments, this composition local will return `null`.
+ */
+@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public val LocalSession: ProvidableCompositionLocal<Session?> =
+    compositionLocalWithComputedDefaultOf {
+        if (SpatialConfiguration.hasXrSpatialFeature(LocalContext.currentValue)) {
+            Session.create(LocalContext.currentValue.getActivity())
+        } else {
+            null
+        }
+    }
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Logger.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Logger.kt
new file mode 100644
index 0000000..ef412ee
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/Logger.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.annotation.RestrictTo
+
+/**
+ * Logger for Subspace Compose. This is a simple wrapper around Log.v that is only enabled if the
+ * androidx.xr.compose.platform.Logger metadata flag is set in the AndroidManifest.xml.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object Logger {
+    private const val METADATA_KEY = "androidx.xr.compose.platform.Logger"
+    private var isDebug: Boolean = false
+
+    /**
+     * Initializes the logger. This method expects
+     * android:name="androidx.xr.compose.platform.Logger" to be set in a metadata tag in the
+     * AndroidManifest.xml. Typically the value of this key is set to false but can be set to true
+     * to enable debug logging. For example:
+     *
+     * <meta-data android:name="androidx.xr.compose.platform.Logger" android:value="true" />
+     *
+     * @param context The application context for the current application.
+     */
+    public fun init(context: Context) {
+        isDebug =
+            context.packageManager
+                .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
+                .metaData
+                .getBoolean(METADATA_KEY, false)
+    }
+
+    /**
+     * Logs a message to the logcat if debugging was enabled in the init method.
+     *
+     * @param tag The tag to use for the log message.
+     * @param messageGenerator This generates the message to log.
+     */
+    public fun log(tag: String, messageGenerator: () -> String) {
+        if (isDebug) {
+            Log.v(tag, messageGenerator())
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SceneManager.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SceneManager.kt
new file mode 100644
index 0000000..3656543
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SceneManager.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import android.util.CloseGuard
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+
+/**
+ * Manager for all [SpatialComposeScene]s that are created when the [SceneManager] is running.
+ *
+ * This is used by the testing framework to keep track of all scene compositions that were created
+ * for the purpose of finding the semantic roots.
+ */
+@Suppress("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object SceneManager : AutoCloseable {
+    private val registeredScenes: MutableList<SpatialComposeScene> = mutableListOf()
+    private var isRunning = false
+    private val guard = CloseGuard()
+
+    /**
+     * Start keeping track of the scenes that are created. Scenes created before [SceneManager] is
+     * running will not be tracked.
+     */
+    public fun start() {
+        isRunning = true
+        guard.open("stop")
+    }
+
+    /**
+     * Stop tracking the created scenes and clear the set of scenes that [SceneManager] was keeping
+     * track of.
+     */
+    public fun stop() {
+        guard.close()
+        isRunning = false
+        registeredScenes.clear()
+    }
+
+    /** Alias to [SceneManager.stop] To implement the [AutoCloseable] interface. */
+    override fun close() {
+        stop()
+    }
+
+    internal fun onSceneCreated(scene: SpatialComposeScene) {
+        if (isRunning) {
+            registeredScenes.add(scene)
+        }
+    }
+
+    internal fun onSceneDisposed(scene: SpatialComposeScene) {
+        if (isRunning) {
+            registeredScenes.remove(scene)
+        }
+    }
+
+    /**
+     * Returns all root subspace semantics nodes of all registered scenes.
+     *
+     * [SceneManager.start] should be called before attempting to get the root subspace semantics
+     * nodes. This will throw an [IllegalStateException] if the [SceneManager] is not in a running
+     * state.
+     */
+    public fun getAllRootSubspaceSemanticsNodes(): List<SubspaceSemanticsNode> {
+        check(isRunning) { "SceneManager is not started. Call SceneManager.start() first." }
+        return registeredScenes.map { SubspaceSemanticsNode(it.rootElement.compositionOwner.root) }
+    }
+
+    public fun getSceneCount(): Int {
+        return registeredScenes.size
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SessionCallbackProvider.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SessionCallbackProvider.kt
new file mode 100644
index 0000000..1f91439
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SessionCallbackProvider.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.annotation.RestrictTo
+import androidx.xr.scenecore.Dimensions
+import androidx.xr.scenecore.Session
+import java.io.Closeable
+
+/** Provides a [SessionCallbacks] for the current Activity. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface SessionCallbackProvider {
+    public operator fun get(session: Session): SessionCallbacks
+
+    public companion object {
+        public val default: SessionCallbackProvider =
+            object : SessionCallbackProvider {
+                val callbackMap = mutableMapOf<Session, SessionCallbacks>()
+
+                override fun get(session: Session): SessionCallbacks =
+                    callbackMap.computeIfAbsent(session, ::DefaultSessionCallbacks)
+            }
+    }
+}
+
+/** Class to store callback lambdas associated with the Session. */
+@Suppress("SingularCallback")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SessionCallbacks {
+    /**
+     * Register a [callback] to be triggered when the app goes into full space mode. It will be
+     * called immediately upon registration if the system is currently in full space mode and its
+     * state is known.
+     *
+     * @param callback the method that will be called when the application enters full space mode.
+     * @return a closeable to unregister the callback
+     */
+    public fun onFullSpaceMode(callback: () -> Unit): Closeable
+
+    /**
+     * Register a [callback] to be triggered when the app goes into home space mode. It will be
+     * called immediately upon registration if the system is currently in home space mode and its
+     * state is known.
+     *
+     * @param callback the method that will be called when the application enters home space mode.
+     *   It will be called with the current application bounds as its argument.
+     * @return a closeable to unregister the callback
+     */
+    public fun onHomeSpaceMode(callback: (Dimensions) -> Unit): Closeable
+
+    /**
+     * Register a [callback] to be triggered when the app changes space mode. It will be called
+     * immediately with the current state if that state is available.
+     *
+     * @param callback the method that will be called when the space mode changes. It will accept an
+     *   enum [SpaceMode] value indicating the current mode.
+     * @return a closeable to unregister the callback
+     */
+    public fun onSpaceModeChanged(callback: (spaceMode: SpaceMode) -> Unit): Closeable
+
+    /**
+     * Register a [callback] to be triggered when the bounds of the app change. It will be called
+     * immediately with the current state if that state is available.
+     *
+     * @param callback the method that will be called when the application bounds change. The
+     *   argument passed to the callback represents the current bounds of the application.
+     * @return a closeable to unregister the callback
+     */
+    public fun onBoundsChanged(callback: (Dimensions) -> Unit): Closeable
+}
+
+/**
+ * Represents immersion level capabilities of the current application environment.
+ *
+ * In XR environments, there are a few different modes that an application can run in:
+ * - Home Space Mode - the application can run side-by-side with other applications for
+ *   multitasking; however, spatialization (e.g. Orbiters, Panels, 3D objects, etc.) is not allowed.
+ * - Full Space Mode - the application is given the entire space and has access to spatialization.
+ *   It may render multiple panels and 3D objects.
+ */
+@JvmInline
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public value class SpaceMode private constructor(public val value: Int) {
+    public companion object {
+        /**
+         * Current space mode or capabilities unknown. Awaiting checks to complete to indicate the
+         * proper space mode.
+         */
+        public val Unspecified: SpaceMode = SpaceMode(0)
+
+        /**
+         * Space mode is not applicable to the current system. This indicates that the current
+         * system is not XR (e.g. Phone, Tablet, etc.)
+         */
+        public val NotApplicable: SpaceMode = SpaceMode(1)
+
+        /**
+         * The XR application is currently in Full Space Mode.
+         *
+         * In Full Space Mode, the application is given the entire space for its own content. It has
+         * access to spatialization and may render multiple panels and 3D objects.
+         */
+        public val Full: SpaceMode = SpaceMode(2)
+
+        /**
+         * The XR application is currently in Home Space Mode.
+         *
+         * In Home Space Mode, the application does not have access to spatialization. It may not
+         * render multiple panels, orbiters, or 3D objects. However, it may run side-by-side with
+         * other applications for multitasking.
+         */
+        public val Home: SpaceMode = SpaceMode(3)
+    }
+}
+
+private class DefaultSessionCallbacks(session: Session) : SessionCallbacks {
+    private var currentDimensions: Dimensions? = null
+    private val onBoundsChangeListeners: MutableList<(Dimensions) -> Unit> = mutableListOf()
+
+    init {
+        // TODO: b/370618645 - This uses a direct executor for the callbacks, to maintain consistent
+        // behavior for clients using Closeables returned by other methods in this class. We should
+        // reconsider this approach, see the linked bug for more details.
+        session.activitySpace.addBoundsChangedListener({ runnable -> runnable.run() }) {
+            onBoundsChangeListeners.forEach { callback -> callback.invoke(it) }
+            currentDimensions = it
+        }
+    }
+
+    override fun onFullSpaceMode(callback: () -> Unit): Closeable = add {
+        if (it.isFullSpace) {
+            callback()
+        }
+    }
+
+    override fun onHomeSpaceMode(callback: (Dimensions) -> Unit): Closeable = add {
+        if (!it.isFullSpace) {
+            callback(it)
+        }
+    }
+
+    override fun onSpaceModeChanged(callback: (SpaceMode) -> Unit): Closeable = add {
+        // Invoke the callback if the current full space state is not equal to the next full
+        // space state or if the current dimensions are the same object reference as the
+        // next dimensions (initial call when this callback is registered)
+        if (it.isFullSpace != currentDimensions?.isFullSpace || it === currentDimensions) {
+            callback(if (it.isFullSpace) SpaceMode.Full else SpaceMode.Home)
+        }
+    }
+
+    override fun onBoundsChanged(callback: (Dimensions) -> Unit): Closeable = add(callback)
+
+    private fun add(callback: (nextDimensions: Dimensions) -> Unit): Closeable {
+        if (currentDimensions != null) {
+            callback(currentDimensions!!)
+        }
+        onBoundsChangeListeners.add(callback)
+        return Closeable {
+
+            // Tests break if this line stays uncommented
+            onBoundsChangeListeners.remove(callback)
+        }
+    }
+}
+
+private val Dimensions.isFullSpace: Boolean
+    get() =
+        width == Float.POSITIVE_INFINITY &&
+            height == Float.POSITIVE_INFINITY &&
+            depth == Float.POSITIVE_INFINITY
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialCapabilities.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialCapabilities.kt
new file mode 100644
index 0000000..d30726e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialCapabilities.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.CompositionLocal
+import androidx.compose.runtime.compositionLocalWithComputedDefaultOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.xr.scenecore.Session
+import androidx.xr.scenecore.SpatialCapabilities.Companion.SPATIAL_CAPABILITY_3D_CONTENT
+import androidx.xr.scenecore.SpatialCapabilities.Companion.SPATIAL_CAPABILITY_APP_ENVIRONMENT
+import androidx.xr.scenecore.SpatialCapabilities.Companion.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL
+import androidx.xr.scenecore.SpatialCapabilities.Companion.SPATIAL_CAPABILITY_SPATIAL_AUDIO
+import androidx.xr.scenecore.SpatialCapabilities.Companion.SPATIAL_CAPABILITY_UI
+
+@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public val LocalSpatialCapabilities: CompositionLocal<SpatialCapabilities> =
+    compositionLocalWithComputedDefaultOf {
+        if (LocalHasXrSpatialFeature.currentValue) {
+            SpatialCapabilities.getOrCreate(
+                checkNotNull(LocalSession.currentValue) { "Session must be initialized." }
+            )
+        } else {
+            NoSpatialCapabilities()
+        }
+    }
+
+/**
+ * Provides information and functionality related to the spatial capabilities of the application.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialCapabilities {
+    /**
+     * Indicates whether the application may create spatial UI elements (e.g. SpatialPanel).
+     *
+     * This is a State-based value that should trigger recomposition in composable functions.
+     */
+    public val isSpatialUiEnabled: Boolean
+
+    /**
+     * Indicates whether the application may create 3D objects.
+     *
+     * This is a State-based value that should trigger recomposition in composable functions.
+     */
+    public val isContent3dEnabled: Boolean
+
+    /**
+     * Indicates whether the application may set the environment.
+     *
+     * This is a State-based value that should trigger recomposition in composable functions.
+     */
+    public val isAppEnvironmentEnabled: Boolean
+
+    /**
+     * Indicates whether the application may control the passthrough state.
+     *
+     * This is a State-based value that should trigger recomposition in composable functions.
+     */
+    public val isPassthroughControlEnabled: Boolean
+
+    /**
+     * Indicates whether the application may use spatial audio.
+     *
+     * This is a State-based value that should trigger recomposition in composable functions.
+     */
+    public val isSpatialAudioEnabled: Boolean
+
+    public companion object {
+        private val sessionInstances: MutableMap<Session, SpatialCapabilities> = mutableMapOf()
+
+        public fun getOrCreate(session: Session): SpatialCapabilities =
+            sessionInstances.getOrPut(session) { SessionSpatialCapabilities(session) }
+    }
+}
+
+private class SessionSpatialCapabilities(session: Session) : SpatialCapabilities {
+    private var capabilities by
+        mutableStateOf(session.getSpatialCapabilities()).apply {
+            session.addSpatialCapabilitiesChangedListener { value = it }
+        }
+
+    override val isSpatialUiEnabled: Boolean
+        get() = capabilities.hasCapability(SPATIAL_CAPABILITY_UI)
+
+    override val isContent3dEnabled: Boolean
+        get() = capabilities.hasCapability(SPATIAL_CAPABILITY_3D_CONTENT)
+
+    override val isAppEnvironmentEnabled: Boolean
+        get() = capabilities.hasCapability(SPATIAL_CAPABILITY_APP_ENVIRONMENT)
+
+    override val isPassthroughControlEnabled: Boolean
+        get() = capabilities.hasCapability(SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL)
+
+    override val isSpatialAudioEnabled: Boolean
+        get() = capabilities.hasCapability(SPATIAL_CAPABILITY_SPATIAL_AUDIO)
+}
+
+private class NoSpatialCapabilities : SpatialCapabilities {
+    override val isSpatialUiEnabled: Boolean = false
+    override val isContent3dEnabled: Boolean = false
+    override val isAppEnvironmentEnabled: Boolean = false
+    override val isPassthroughControlEnabled: Boolean = false
+    override val isSpatialAudioEnabled: Boolean = false
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeElement.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeElement.kt
new file mode 100644
index 0000000..d49541d
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeElement.kt
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Composition
+import androidx.compose.runtime.CompositionContext
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.layout.CoreEntity
+
+/**
+ * Base class for custom [SpatialElement]s implemented using Jetpack Compose UI.
+ *
+ * Subclasses should implement the [Content] function with the appropriate content.
+ *
+ * Attempts to call [addChild] or its variants/overloads will result in an [IllegalStateException].
+ *
+ * This class is based on the existing `AbstractComposeView` class in
+ * [androidx.compose.ui.platform].
+ *
+ * @property compositionContext The [CompositionContext] used for compositions of this element's
+ *   hierarchy.
+ *
+ *   If this composition was created as the top level composition in the hierarchy, then the
+ *   recomposer will be cancelled if this element is detached from the subspace. Therefore, this
+ *   instance should not be shared or reused in other trees.
+ *
+ * @property rootCoreEntity The [CoreEntity] associated with the root of this composition. This root
+ *   CoreEntity will be the parent entity of the entire composition.
+ *
+ *   It is not necessary for a composition to have a root entity; however, it may be provided to
+ *   ensure that the composition is properly parented when it is a sub-composition of another
+ *   composition.
+ */
+internal abstract class AbstractComposeElement(
+    internal var compositionContext: CompositionContext? = null,
+    internal val rootCoreEntity: CoreEntity? = null,
+) : SpatialElement() {
+
+    /**
+     * Whether the composition should be created when the element is attached to a
+     * [SpatialComposeScene].
+     *
+     * If `true`, this [SpatialElement]'s composition will be created when it is attached to a
+     * [SpatialComposeScene] for the first time. Defaults to `true`.
+     *
+     * Subclasses may override this property to prevent eager initial composition if the element's
+     * content is not yet ready.
+     */
+    @get:Suppress("GetterSetterNames")
+    protected open val shouldCreateCompositionOnAttachedToSpatialComposeScene: Boolean
+        get() = true
+
+    private var creatingComposition = false
+
+    private var composition: Composition? = null
+
+    /**
+     * The [AndroidComposeSpatialElement] that will be used to host the composition for this
+     * element.
+     */
+    internal val compositionOwner: AndroidComposeSpatialElement = AndroidComposeSpatialElement()
+
+    /**
+     * The Jetpack Compose [SubspaceComposable] UI content for this element.
+     *
+     * Subclasses must implement this method to provide content. Initial composition will occur when
+     * the element is attached to a [SpatialComposeScene] or when [createComposition] is called,
+     * whichever comes first.
+     */
+    @Composable @SubspaceComposable protected abstract fun Content()
+
+    override fun addChild(element: SpatialElement) {
+        if (!creatingComposition) {
+            throw UnsupportedOperationException(
+                "May only add $element to $this during composition."
+            )
+        }
+
+        super.addChild(element)
+    }
+
+    override fun onAttachedToSubspace(spatialComposeScene: SpatialComposeScene) {
+        super.onAttachedToSubspace(spatialComposeScene)
+
+        if (shouldCreateCompositionOnAttachedToSpatialComposeScene) {
+            createComposition()
+        }
+    }
+
+    /**
+     * Performs the initial composition for this element.
+     *
+     * This method has no effect if the composition has already been created.
+     *
+     * This method should only be called if this element is attached to a [SpatialComposeScene] or
+     * if a parent [CompositionContext] has been set explicitly.
+     */
+    @OptIn(InternalComposeUiApi::class)
+    protected fun createComposition() {
+        if (composition != null) return
+
+        check(isAttachedToSpatialComposeScene) {
+            "Element.createComposition() requires the Element to be attached to the subspace."
+        }
+        check(!hasAncestorWithCompositionContext()) {
+            "Cannot construct a composition for $this element. The tree it is currently attached " +
+                "to has another composition context."
+        }
+        check(children.isEmpty()) {
+            "Cannot set the composable content. $parent element already contains a subtree."
+        }
+
+        creatingComposition = true
+
+        GlobalSnapshotManager.ensureStarted()
+        addChild(compositionOwner)
+        compositionOwner.root.coreEntity = rootCoreEntity
+        composition =
+            WrappedComposition(
+                    compositionOwner,
+                    compositionContext
+                        ?: SubspaceRecomposerPolicy.createAndInstallSubspaceRecomposer(this),
+                )
+                .also {
+                    compositionOwner.wrappedComposition = it
+                    it.setContent { Content() }
+                }
+
+        creatingComposition = false
+    }
+
+    /** Whether any of the ancestor elements have a [CompositionContext]. */
+    private fun hasAncestorWithCompositionContext(): Boolean =
+        generateSequence(parent) { it.parent }
+            .any { it is AbstractComposeElement && it.compositionContext != null }
+
+    /**
+     * Disposes the composition for this element.
+     *
+     * This method has no effect if the composition has already been disposed.
+     */
+    public fun disposeComposition() {
+        composition?.dispose()
+        composition = null
+    }
+}
+
+/**
+ * An [SpatialElement] that can host Jetpack Compose [SubspaceComposable] content.
+ *
+ * Use [setContent] to provide the content composable function for the element.
+ *
+ * This class is based on the existing `ComposeView` class in [androidx.compose.ui.platform].
+ *
+ * @param scene The [SpatialComposeScene] that this element is attached to.
+ * @param compositionContext the [CompositionContext] from a parent composition to propagate
+ *   composition state. Should be `null` when this instance is the top-level composition context, in
+ *   which case a new [CompositionContext] will be created. This value should be provided when this
+ *   instance is a sub-composition of another composition.
+ * @param rootCoreEntity The [CoreEntity] associated with the root layout of this composition (see
+ *   [AbstractComposeElement.rootCoreEntity])
+ */
+internal class SpatialComposeElement(
+    scene: SpatialComposeScene,
+    compositionContext: CompositionContext? = null,
+    rootCoreEntity: CoreEntity? = null,
+) : AbstractComposeElement(compositionContext, rootCoreEntity) {
+    init {
+        spatialComposeScene = scene
+    }
+
+    private val content = mutableStateOf<(@Composable @SubspaceComposable () -> Unit)?>(null)
+
+    @get:Suppress("GetterSetterNames")
+    override var shouldCreateCompositionOnAttachedToSpatialComposeScene: Boolean = false
+        private set
+
+    @Composable
+    @SubspaceComposable
+    override fun Content() {
+        content.value?.invoke()
+    }
+
+    /**
+     * Sets the Jetpack Compose [SubspaceComposable] UI content for this element.
+     *
+     * Initial composition will occur when the element is attached to a [SpatialComposeScene] or
+     * when [createComposition] is called, whichever comes first.
+     *
+     * @param content the composable content to display in this element.
+     */
+    public fun setContent(content: @Composable @SubspaceComposable () -> Unit) {
+        shouldCreateCompositionOnAttachedToSpatialComposeScene = true
+
+        this.content.value = content
+
+        if (isAttachedToSpatialComposeScene) {
+            createComposition()
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeScene.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeScene.kt
new file mode 100644
index 0000000..618b9fa
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialComposeScene.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionContext
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.layout.CoreEntity
+import androidx.xr.scenecore.Session
+
+/**
+ * A 3D scene represented via Compose elements and coordinated with SceneCore.
+ *
+ * This class manages the lifecycle and root element of the spatial scene. It also provides access
+ * to the SceneCore session and environment.
+ *
+ * @param parentCompositionContext the optional composition context when this is a sub-composition
+ * @param rootEntity the optional [CoreEntity] to associate with the root of this composition
+ * @property ownerActivity the [ComponentActivity] that owns this scene.
+ * @property jxrSession the [Session] used to interact with SceneCore.
+ */
+internal class SpatialComposeScene(
+    /** Context of the activity that this scene is rooted on. */
+    public val ownerActivity: ComponentActivity,
+    @InternalSubspaceApi public val jxrSession: Session,
+    parentCompositionContext: CompositionContext? = null,
+    rootEntity: CoreEntity? = null,
+) : DefaultLifecycleObserver, LifecycleOwner {
+    init {
+        SceneManager.onSceneCreated(this)
+    }
+
+    /** Root of the spatial scene graph of this [SpatialComposeScene]. */
+    internal val rootElement: SpatialComposeElement =
+        SpatialComposeElement(this, parentCompositionContext, rootEntity)
+
+    public fun setContent(content: @Composable @SubspaceComposable () -> Unit) {
+        rootElement.setContent(content)
+    }
+
+    public fun dispose() {
+        rootElement.disposeComposition()
+        SceneManager.onSceneDisposed(this)
+    }
+
+    override val lifecycle: Lifecycle
+        get() = ownerActivity.lifecycle
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialConfiguration.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialConfiguration.kt
new file mode 100644
index 0000000..3e7f1d6
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialConfiguration.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import android.content.Context
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.CompositionLocal
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalWithComputedDefaultOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.compose.unit.toDpVolumeSize
+import androidx.xr.scenecore.Session
+
+/**
+ * The name of the system feature that indicates whether the system supports XR Spatial features.
+ */
+internal const val XR_IMMERSIVE_FEATURE = "android.software.xr.immersive"
+
+/** CompositionLocal indicating whether the system XR Spatial feature is enabled. */
+@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public val LocalHasXrSpatialFeature: ProvidableCompositionLocal<Boolean> =
+    compositionLocalWithComputedDefaultOf {
+        SpatialConfiguration.hasXrSpatialFeature(LocalContext.currentValue)
+    }
+
+@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public val LocalSpatialConfiguration: CompositionLocal<SpatialConfiguration> =
+    compositionLocalWithComputedDefaultOf {
+        if (LocalHasXrSpatialFeature.currentValue) {
+            SpatialConfiguration.getOrCreate(
+                checkNotNull(LocalSession.currentValue) { "session must be initialized" },
+                LocalHasXrSpatialFeature.currentValue,
+            )
+        } else {
+            ContextOnlySpatialConfiguration(LocalContext.currentValue)
+        }
+    }
+
+/**
+ * Provides information and functionality related to the spatial configuration of the application.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialConfiguration {
+    /**
+     * A volume whose width, height, and depth represent the space available to the application.
+     *
+     * In XR, an application's available space is not related to the display or window dimensions;
+     * instead, it will always be some subset of the virtual 3D space. The app bounds will change
+     * when switching between home space or full space modes.
+     *
+     * In non-XR environments, the width and height will represent the screen width and height
+     * available to the application (see [android.content.res.Configuration.screenWidthDp] and
+     * [android.content.res.Configuration.screenHeightDp]) and the depth will be zero.
+     *
+     * This is a state-based value that will trigger recomposition.
+     */
+    public val bounds: DpVolumeSize
+        get() = DpVolumeSize.Zero
+
+    /**
+     * XR Spatial APIs are supported for this system. This is equivalent to
+     * PackageManager.hasSystemFeature(FEATURE_XR_SPATIAL, version) where version is the minimum
+     * version for features available in the XR Compose library used.
+     */
+    @Suppress("INAPPLICABLE_JVM_NAME")
+    @get:JvmName("hasXrSpatialFeature")
+    public val hasXrSpatialFeature: Boolean
+        get() = false
+
+    /**
+     * Request that the system places the application into home space mode. This will execute
+     * asynchronously. If it completes successfully then [bounds] will change. This method will
+     * throw an [UnsupportedOperationException] if the application is not in an XR environment.
+     *
+     * In home space, the visible space may be shared with other applications; however, applications
+     * in home space will have their spatial capabilities and physical bounds limited.
+     */
+    public fun requestHomeSpaceMode() {
+        throw UnsupportedOperationException(
+            "Cannot request mode changes when not in an Android XR environment."
+        )
+    }
+
+    /**
+     * Request that the system places the application into full space mode. This will execute
+     * asynchronously. If it completes successfully then [bounds] will change. This method will
+     * throw an [UnsupportedOperationException] if the application is not in an XR environment.
+     *
+     * In full space, this application will be the only application in the visible space, its
+     * spatial capabilities will be expanded, and its physical bounds will expand to fill the entire
+     * virtual space.
+     */
+    public fun requestFullSpaceMode() {
+        throw UnsupportedOperationException(
+            "Cannot request mode changes when not in an Android XR environment."
+        )
+    }
+
+    public companion object {
+        /**
+         * XR Spatial APIs are supported for this system. This is equivalent to
+         * PackageManager.hasSystemFeature(FEATURE_XR_SPATIAL, version) where version is the minimum
+         * version for features available in the XR Compose library used.
+         */
+        public fun hasXrSpatialFeature(context: Context): Boolean {
+            return context.packageManager.hasSystemFeature(XR_IMMERSIVE_FEATURE)
+        }
+
+        private val sessionInstances: MutableMap<Session, SpatialConfiguration> = mutableMapOf()
+
+        public fun getOrCreate(
+            session: Session,
+            hasXrSpatialFeature: Boolean
+        ): SpatialConfiguration =
+            sessionInstances.getOrPut(session) {
+                SessionSpatialConfiguration(session, hasXrSpatialFeature)
+            }
+    }
+}
+
+/** A [SpatialConfiguration] that only has access to the current activity context. */
+private class ContextOnlySpatialConfiguration(private val context: Context) : SpatialConfiguration {
+    override val hasXrSpatialFeature: Boolean
+        get() = SpatialConfiguration.hasXrSpatialFeature(context)
+
+    override val bounds: DpVolumeSize
+        get() =
+            DpVolumeSize(
+                context.getActivity().resources.configuration.screenWidthDp.dp,
+                context.getActivity().resources.configuration.screenHeightDp.dp,
+                0.dp,
+            )
+}
+
+/** A [SpatialConfiguration] that is attached to the current [Session]. */
+private class SessionSpatialConfiguration(
+    private val session: Session,
+    override val hasXrSpatialFeature: Boolean,
+) : SpatialConfiguration {
+    private var boundsState by
+        mutableStateOf(session.activitySpace.getBounds()).apply {
+            session.activitySpace.addBoundsChangedListener { value = it }
+        }
+
+    override val bounds: DpVolumeSize
+        get() = boundsState.toDpVolumeSize()
+
+    override fun requestHomeSpaceMode() {
+        session.requestHomeSpaceMode()
+    }
+
+    override fun requestFullSpaceMode() {
+        session.requestFullSpaceMode()
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialElement.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialElement.kt
new file mode 100644
index 0000000..040eeae
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SpatialElement.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.annotation.CallSuper
+
+/**
+ * Represents the basic building block for a [SpatialElement] that can contain other
+ * [SpatialElement] instances.
+ *
+ * It provides functionality for managing child SpatialElements and for attaching and detaching from
+ * a [SpatialComposeScene].
+ */
+internal open class SpatialElement {
+
+    /**
+     * Interface definition for callbacks that are invoked when this element's attachment state
+     * changes.
+     */
+    public interface OnAttachStateChangeListener {
+        /**
+         * Called when the [SpatialElement] is attached to a [SpatialComposeScene].
+         *
+         * @param spatialComposeScene The [SpatialComposeScene] that the element was attached to.
+         */
+        public fun onElementAttachedToSubspace(spatialComposeScene: SpatialComposeScene) {}
+
+        /**
+         * Called when the [SpatialElement] is detached from a [SpatialComposeScene].
+         *
+         * @param spatialComposeScene The [SpatialComposeScene] that the element was detached from.
+         */
+        public fun onElementDetachedFromSubspace(spatialComposeScene: SpatialComposeScene) {}
+    }
+
+    /**
+     * The parent of this [SpatialElement].
+     *
+     * This property is `null` if the element is not attached to a parent [SpatialElement].
+     */
+    public var parent: SpatialElement? = null
+        internal set(parentEl) {
+            field = parentEl
+            spatialComposeScene = parentEl?.spatialComposeScene
+        }
+
+    /**
+     * The [SpatialComposeScene] that this element is attached to, or `null` if it is not attached
+     * to any subspace.
+     */
+    public var spatialComposeScene: SpatialComposeScene? = null
+        set(value) {
+            if (field == value) {
+                return
+            }
+
+            if (value == null) {
+                val oldScene = checkNotNull(field) { "Scene must be non-null before clearing." }
+                onDetachedFromSubspace(oldScene)
+                onAttachStateChangeListeners?.forEach { it.onElementDetachedFromSubspace(oldScene) }
+            } else {
+                onAttachedToSubspace(value)
+                onAttachStateChangeListeners?.forEach { it.onElementAttachedToSubspace(value) }
+            }
+            field = value
+        }
+
+    /** Whether the element is attached to a [SpatialComposeScene]. */
+    protected val isAttachedToSpatialComposeScene: Boolean
+        get() = spatialComposeScene != null
+
+    private var onAttachStateChangeListeners: MutableList<OnAttachStateChangeListener>? = null
+
+    /**
+     * Detaches this element from its parent [SpatialElement], setting the [parent] to `null` and
+     * removing from parent's [SpatialElement.children] list.
+     */
+    private fun detachFromParent() {
+        @Suppress("UNUSED_VARIABLE") val unused = parent?.removeChild(this)
+        parent = null
+    }
+
+    /**
+     * Registers the [listener] whose callbacks are invoked when this [SpatialElement]'s attachment
+     * state to [SpatialComposeScene] changes.
+     *
+     * Use [removeOnAttachStateChangeListener] to unregister the [listener].
+     */
+    @Suppress("ExecutorRegistration")
+    public fun addOnAttachStateChangeListener(listener: OnAttachStateChangeListener) {
+        val listeners = onAttachStateChangeListeners
+
+        if (listeners == null) {
+            onAttachStateChangeListeners = mutableListOf(listener)
+        } else {
+            listeners.add(listener)
+        }
+    }
+
+    /**
+     * Registers a one-time callback to be invoked when this [SpatialElement] is detached from the
+     * [SpatialComposeScene].
+     *
+     * @param listener the callback to be invoked when the element is detached.
+     */
+    public fun onDetachedFromSubspaceOnce(listener: (SpatialComposeScene) -> Unit) {
+        addOnAttachStateChangeListener(
+            object : OnAttachStateChangeListener {
+                override fun onElementDetachedFromSubspace(
+                    spatialComposeScene: SpatialComposeScene
+                ) {
+                    removeOnAttachStateChangeListener(this)
+                    listener(spatialComposeScene)
+                }
+            }
+        )
+    }
+
+    /**
+     * Removes and unregisters the previously registered [listener].
+     *
+     * The [listener] will no longer receive any further notification whenever [spatialComposeScene]
+     * attachment changes.
+     */
+    public fun removeOnAttachStateChangeListener(listener: OnAttachStateChangeListener) {
+        onAttachStateChangeListeners?.remove(listener)
+    }
+
+    private val _children = mutableListOf<SpatialElement>()
+
+    /** Immediate children of this [SpatialElement]. */
+    public val children: List<SpatialElement>
+        get() = _children
+
+    /**
+     * Appends the given [element] to the [children] of this element.
+     *
+     * @param element the element to add as a child.
+     */
+    @CallSuper
+    public open fun addChild(element: SpatialElement) {
+        element.parent = this
+        _children.add(element)
+    }
+
+    /**
+     * Removes the given [element] from the [children] of this element.
+     *
+     * @param element the element to remove as a child.
+     * @return `true` if element was successfully removed from the list, `false` otherwise.
+     */
+    @CallSuper
+    public open fun removeChild(element: SpatialElement): Boolean {
+        if (_children.remove(element)) {
+            element.parent = null
+            return true
+        }
+
+        return false
+    }
+
+    /** Detaches all of its children and clears the [children] list. */
+    @CallSuper
+    public open fun removeChildren() {
+        _children.forEach { it.parent = null }
+        _children.clear()
+    }
+
+    /**
+     * Update children's spatialComposeScene to [SpatialComposeScene] when this element is attached
+     * to it.
+     *
+     * @param spatialComposeScene the [SpatialComposeScene] this element is attached to.
+     */
+    @CallSuper
+    public open fun onAttachedToSubspace(spatialComposeScene: SpatialComposeScene) {
+        // Make sure all children have the same `spatialComposeScene` reference too.
+        _children.forEach { it.spatialComposeScene = spatialComposeScene }
+    }
+
+    /**
+     * Called when this element is detached from a [SpatialComposeScene].
+     *
+     * @param spatialComposeScene the [SpatialComposeScene] this element is detached from.
+     */
+    @CallSuper
+    public open fun onDetachedFromSubspace(spatialComposeScene: SpatialComposeScene) {
+        // make sure `spatialComposeScene` references of all children are cleaned up too.
+        _children.forEach { it.spatialComposeScene = null }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SubspaceRecomposer.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SubspaceRecomposer.kt
new file mode 100644
index 0000000..cad2eb6
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/SubspaceRecomposer.kt
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import android.content.Context
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Looper
+import android.provider.Settings
+import androidx.compose.runtime.MonotonicFrameClock
+import androidx.compose.runtime.PausableMonotonicFrameClock
+import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.compose.ui.MotionDurationScale
+import androidx.compose.ui.platform.AndroidUiDispatcher
+import androidx.core.os.HandlerCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/** See [androidx.compose.ui.platform.WindowRecomposerPolicy] */
+@InternalComposeUiApi
+internal object SubspaceRecomposerPolicy {
+    private val factory = AtomicReference(SubspaceRecomposerFactory.LifecycleAware)
+
+    internal fun createAndInstallSubspaceRecomposer(
+        rootElement: AbstractComposeElement
+    ): Recomposer {
+        val newRecomposer = factory.get().createRecomposer(rootElement)
+        rootElement.compositionContext = newRecomposer
+
+        // If the Recomposer shuts down, unregister it so that a future request for a subspace
+        // recomposer will consult the factory for a new one.
+        // TODO: update to GlobalScope.launch(AndroidUiDispatcher.CurrentThread) when migrating
+        //       to androidx-main
+        val scope = MainScope()
+        val unsetJob =
+            scope.launch {
+                try {
+                    newRecomposer.join()
+                } finally {
+                    // Unset if the element is detached. (See below for the attach state change
+                    // listener). Since this is in a finally in this coroutine, even if this job is
+                    // cancelled we will resume on the window's UI thread and perform this
+                    // manipulation
+                    // there.
+                    if (rootElement.compositionContext === newRecomposer) {
+                        rootElement.compositionContext = null
+                    }
+                }
+            }
+
+        // If the root element is detached, cancel the await for recomposer shutdown above.
+        // This will also unset the tag reference to this recomposer during its cleanup.
+        rootElement.addOnAttachStateChangeListener(
+            object : SpatialElement.OnAttachStateChangeListener {
+                override fun onElementAttachedToSubspace(
+                    spatialComposeScene: SpatialComposeScene
+                ) {}
+
+                override fun onElementDetachedFromSubspace(
+                    spatialComposeScene: SpatialComposeScene
+                ) {
+                    rootElement.removeOnAttachStateChangeListener(this)
+                    // Cancel the job to clean up the composition context reference in the element.
+                    unsetJob.cancel()
+
+                    // Also cancel the recomposer as it is not shared with another tree.
+                    newRecomposer.cancel()
+                }
+            }
+        )
+
+        return newRecomposer
+    }
+}
+
+/**
+ * A factory for creating an subspace-scoped [Recomposer].
+ *
+ * See [createRecomposer] for more info.
+ */
+@InternalComposeUiApi
+private fun interface SubspaceRecomposerFactory {
+    /**
+     * Creates a [Recomposer] for a subspace with [rootElement] being the root of the Compose
+     * hierarchy.
+     *
+     * The factory is responsible for establishing a policy for [shutting down][Recomposer.cancel]
+     * the returned [Recomposer]. [rootElement] will hold a hard reference to the returned
+     * [Recomposer] until it [joins][Recomposer.join] after shutting down.
+     */
+    fun createRecomposer(rootElement: AbstractComposeElement): Recomposer
+
+    companion object {
+        /**
+         * A [SubspaceRecomposerFactory] that creates **lifecycle-aware** [Recomposer]s.
+         *
+         * Returned [Recomposer]s will be bound to the [SpatialComposeScene] of the 'rootElement'
+         * argument of [createRecomposer] and will be destroyed once the [SpatialComposeScene]
+         * lifecycle ends.
+         *
+         * The recomposer will run [recomposition][Recomposer.runRecomposeAndApplyChanges] and
+         * composition effects on the [AndroidUiDispatcher.CurrentThread]. The associated
+         * [MonotonicFrameClock] will only produce frames when the [Lifecycle] is at least
+         * [Lifecycle.State.STARTED], causing animations and other uses of [MonotonicFrameClock]
+         * APIs to suspend until a **visible** frame will be produced.
+         */
+        @OptIn(ExperimentalComposeUiApi::class)
+        val LifecycleAware: SubspaceRecomposerFactory = SubspaceRecomposerFactory { rootElement ->
+            createLifecycleAwareSubspaceRecomposer(rootElement)
+        }
+    }
+}
+
+/**
+ * Create a [Lifecycle] and
+ * [subspace attachment][SpatialElement.isAttachedToSpatialComposeScene]-aware [Recomposer] for the
+ * [rootElement] with the same behavior as [SubspaceRecomposerFactory.LifecycleAware].
+ *
+ * [coroutineContext] will override any [CoroutineContext] elements from the default configuration
+ * normally used for this content element. The default [CoroutineContext] contains
+ * [AndroidUiDispatcher.CurrentThread];
+ *
+ * This function should only be called from the UI thread of this [SpatialElement] or its intended
+ * UI thread if it is currently detached. It must also only be called when the element is attached
+ * to a subspace, i.e., [SpatialElement.spatialComposeScene] must not be `null`. If the
+ * [SpatialElement.spatialComposeScene] is `null`, an [IllegalStateException] will be thrown.
+ *
+ * The returned [Recomposer] will be [cancelled][Recomposer.cancel] when the [rootElement] is
+ * detached from its subspace or if its determined that the subspace is destroyed and its
+ * [Lifecycle] has [ended][Lifecycle.Event.ON_DESTROY].
+ *
+ * Recomposition and associated [frame-based][MonotonicFrameClock] effects may be throttled or
+ * paused while the [Lifecycle] is not at least [Lifecycle.State.STARTED].
+ */
+@ExperimentalComposeUiApi
+private fun createLifecycleAwareSubspaceRecomposer(
+    rootElement: AbstractComposeElement,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+): Recomposer {
+    val subspace =
+        checkNotNull(rootElement.spatialComposeScene) {
+            "Element $rootElement is not attached to a subspace."
+        }
+
+    // Only access AndroidUiDispatcher.CurrentThread if we would use an element from it,
+    // otherwise prevent lazy initialization.
+    val baseContext =
+        if (
+            coroutineContext[ContinuationInterceptor] == null ||
+                coroutineContext[MonotonicFrameClock] == null
+        ) {
+            AndroidUiDispatcher.CurrentThread + coroutineContext
+        } else {
+            coroutineContext
+        }
+
+    val pausableClock =
+        baseContext[MonotonicFrameClock]?.let { PausableMonotonicFrameClock(it).apply { pause() } }
+
+    var systemDurationScaleSettingConsumer: MotionDurationScaleImpl? = null
+    val motionDurationScale =
+        baseContext[MotionDurationScale]
+            ?: MotionDurationScaleImpl().also { systemDurationScaleSettingConsumer = it }
+
+    val contextWithClockAndMotionScale =
+        baseContext + (pausableClock ?: EmptyCoroutineContext) + motionDurationScale
+    val recomposer =
+        Recomposer(contextWithClockAndMotionScale).also { it.pauseCompositionFrameClock() }
+    val runRecomposeScope = CoroutineScope(contextWithClockAndMotionScale)
+
+    // Removing the element that holds the spatial scene graph means it may never be reattached
+    // again.
+    // Since this factory function is used to create a new recomposer for each invocation and
+    // does not reuse a single instance like other factories might, shut it down whenever it
+    // becomes detached. This can easily happen as part of subspace content.
+    rootElement.onDetachedFromSubspaceOnce { recomposer.cancel() }
+
+    subspace.lifecycle.addObserver(
+        object : LifecycleEventObserver {
+            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+                val self = this
+                when (event) {
+                    Lifecycle.Event.ON_CREATE -> {
+                        // UNDISPATCHED launch since we've configured this scope to be on the UI
+                        // thread.
+                        runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
+                            var durationScaleJob: Job? = null
+                            try {
+                                durationScaleJob =
+                                    systemDurationScaleSettingConsumer?.let {
+                                        val durationScaleStateFlow =
+                                            getAnimationScaleFlowFor(
+                                                subspace.ownerActivity.applicationContext
+                                            )
+                                        it.scaleFactor = durationScaleStateFlow.value
+                                        launch {
+                                            durationScaleStateFlow.collect { scaleFactor ->
+                                                it.scaleFactor = scaleFactor
+                                            }
+                                        }
+                                    }
+                                recomposer.runRecomposeAndApplyChanges()
+                            } finally {
+                                durationScaleJob?.cancel()
+                                // If runRecomposeAndApplyChanges returns or this coroutine is
+                                // cancelled
+                                // it means we no longer care about this lifecycle. Clean up the
+                                // dangling references tied to this observer.
+                                source.lifecycle.removeObserver(self)
+                            }
+                        }
+                    }
+                    Lifecycle.Event.ON_START -> {
+                        // The clock starts life as paused so resume it when starting. If it is
+                        // already
+                        // running (this ON_START is after an ON_STOP) then the resume is ignored.
+                        pausableClock?.resume()
+
+                        // Resumes the frame clock dispatching if this is an ON_START after an
+                        // ON_STOP
+                        // that paused it. If the recomposer is not paused  calling
+                        // `resumeFrameClock()`
+                        // is ignored.
+                        recomposer.resumeCompositionFrameClock()
+                    }
+                    Lifecycle.Event.ON_STOP -> {
+                        // Pause the recomposer's frame clock which will pause all calls to
+                        // `withFrameNanos` (e.g. animations) while the window is stopped.
+                        recomposer.pauseCompositionFrameClock()
+                    }
+                    Lifecycle.Event.ON_DESTROY -> {
+                        recomposer.cancel()
+                    }
+                    Lifecycle.Event.ON_PAUSE -> {
+                        // Nothing
+                    }
+                    Lifecycle.Event.ON_RESUME -> {
+                        // Nothing
+                    }
+                    Lifecycle.Event.ON_ANY -> {
+                        // Nothing
+                    }
+                }
+            }
+        }
+    )
+
+    return recomposer
+}
+
+private class MotionDurationScaleImpl : MotionDurationScale {
+    override var scaleFactor by mutableFloatStateOf(1f)
+}
+
+private val animationScale = mutableMapOf<Context, StateFlow<Float>>()
+
+// Callers of this function should pass an application context. Passing an activity context might
+// result in activity leaks.
+private fun getAnimationScaleFlowFor(applicationContext: Context): StateFlow<Float> {
+    return synchronized(animationScale) {
+        animationScale.getOrPut(applicationContext) {
+            val resolver = applicationContext.contentResolver
+            val animationScaleUri =
+                Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE)
+            val channel = Channel<Unit>(CONFLATED)
+            val contentObserver =
+                object : ContentObserver(HandlerCompat.createAsync(Looper.getMainLooper())) {
+                    override fun onChange(selfChange: Boolean, uri: Uri?) {
+                        @Suppress("UNUSED_VARIABLE") val unused = channel.trySend(Unit)
+                    }
+                }
+
+            callbackFlow {
+                    resolver.registerContentObserver(animationScaleUri, false, contentObserver)
+                    try {
+                        for (value in channel) {
+                            val newValue =
+                                Settings.Global.getFloat(
+                                    applicationContext.contentResolver,
+                                    Settings.Global.ANIMATOR_DURATION_SCALE,
+                                    1f,
+                                )
+                            send(newValue)
+                        }
+                    } finally {
+                        resolver.unregisterContentObserver(contentObserver)
+                    }
+                }
+                .stateIn(
+                    MainScope(),
+                    SharingStarted.WhileSubscribed(),
+                    Settings.Global.getFloat(
+                        applicationContext.contentResolver,
+                        Settings.Global.ANIMATOR_DURATION_SCALE,
+                        1f,
+                    ),
+                )
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/WrappedComposition.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/WrappedComposition.kt
new file mode 100644
index 0000000..33b2a27
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/platform/WrappedComposition.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Composition
+import androidx.compose.runtime.CompositionContext
+import androidx.compose.runtime.CompositionServiceKey
+import androidx.compose.runtime.CompositionServices
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.xr.compose.subspace.node.SubspaceNodeApplier
+
+/**
+ * A composition object that is tied to [owner]'s lifecycle and attachment state.
+ *
+ * See [androidx.compose.ui.platform.WrappedComposition]
+ */
+internal class WrappedComposition(
+    private val owner: AndroidComposeSpatialElement,
+    compositionContext: CompositionContext,
+) : Composition, LifecycleEventObserver, CompositionServices {
+
+    private val composition = Composition(SubspaceNodeApplier(owner.root), compositionContext)
+    private var lastContent: @Composable () -> Unit = {}
+    private var addedToLifecycle: Lifecycle? = null
+
+    override val hasInvalidations
+        get() = composition.hasInvalidations
+
+    override val isDisposed: Boolean
+        get() = composition.isDisposed
+
+    override fun setContent(content: @Composable () -> Unit) {
+        owner.setOnSubspaceAvailable {
+            if (isDisposed) return@setOnSubspaceAvailable
+
+            val lifecycle = it.lifecycle
+            lastContent = content
+
+            if (addedToLifecycle == null) {
+                addedToLifecycle = lifecycle
+                // this will call ON_CREATE synchronously if it is already past the created state.
+                lifecycle.addObserver(this)
+            } else if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
+                composition.setContent { ProvideCompositionLocals(owner, content) }
+            }
+        }
+
+        owner.onDetachedFromSubspaceOnce { dispose() }
+    }
+
+    override fun dispose() {
+        if (!isDisposed) {
+            owner.wrappedComposition = null
+            composition.dispose()
+        }
+    }
+
+    override fun <T> getCompositionService(key: CompositionServiceKey<T>): T? =
+        (composition as? CompositionServices)?.getCompositionService(key)
+
+    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+        if (event == Lifecycle.Event.ON_DESTROY) {
+            dispose()
+        } else if (event == Lifecycle.Event.ON_CREATE) {
+            if (!isDisposed) {
+                setContent(lastContent)
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ConstrainToModifier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ConstrainToModifier.kt
new file mode 100644
index 0000000..ae0251f
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ConstrainToModifier.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.Constraints
+
+/** Constrain the layout to the provided Constraints. */
+internal fun Modifier.constrainTo(constraints: Constraints) = layout { measurable, _ ->
+    measurable.measure(constraints).run { layout(width, height) { place(0, 0) } }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ElevatedPanel.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ElevatedPanel.kt
new file mode 100644
index 0000000..ea0180f
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/ElevatedPanel.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import android.view.View
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Transition
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.xr.compose.platform.LocalPanelEntity
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
+import androidx.xr.compose.subspace.layout.SpatialShape
+import androidx.xr.compose.subspace.rememberComposeView
+import androidx.xr.compose.unit.Meter
+import androidx.xr.compose.unit.Meter.Companion.meters
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.Dimensions
+import androidx.xr.scenecore.PanelEntity
+
+internal object ElevatedPanelDefaults {
+    /** Default shape for a Spatial Panel. */
+    internal val shape: SpatialShape = SpatialRoundedCornerShape(ZeroCornerSize)
+}
+
+/**
+ * Defines the "root view" of this ElevatedPanel such that nested ElevatedPanels can all reference
+ * the root view that they are attached to.
+ */
+internal val LocalRootView = compositionLocalOf<View?> { null }
+
+/**
+ * This is the base panel underlying the implementations of ElevatedSurface, ElevatedPopup, and
+ * ElevatedDialog. It allows creating a panel at a specific size and offset.
+ */
+@Composable
+internal fun ElevatedPanel(
+    spatialElevationLevel: SpatialElevationLevel,
+    contentSize: IntSize,
+    shape: SpatialShape = ElevatedPanelDefaults.shape,
+    contentOffset: Offset? = null,
+    transitionSpec:
+        @Composable
+        Transition.Segment<SpatialElevationLevel>.() -> FiniteAnimationSpec<Float> =
+        {
+            spring()
+        },
+    content: @Composable () -> Unit,
+) {
+    val parentView = LocalRootView.current ?: LocalView.current
+    val zDepth by
+        updateTransition(targetState = spatialElevationLevel, label = "restingLevelTransition")
+            .animateFloat(transitionSpec = transitionSpec, label = "zDepth") { state ->
+                state.level
+            }
+    var parentViewSize by remember { mutableStateOf(parentView.size) }
+    DisposableEffect(parentView) {
+        val listener =
+            View.OnLayoutChangeListener { _, _, _, right, bottom, _, _, _, _ ->
+                parentViewSize = IntSize(right, bottom)
+            }
+        parentView.addOnLayoutChangeListener(listener)
+        onDispose { parentView.removeOnLayoutChangeListener(listener) }
+    }
+
+    ElevatedPanel(
+        contentSize = contentSize,
+        shape = shape,
+        pose =
+            contentOffset?.let { rememberCalculatePose(it, parentViewSize, contentSize, zDepth) },
+        content = content,
+    )
+}
+
+/**
+ * This is the base panel underlying the implementations of ElevatedSurface, ElevatedPopup, and
+ * ElevatedDialog. It allows creating a panel at a specific size and [Pose].
+ */
+@Composable
+internal fun ElevatedPanel(
+    contentSize: IntSize,
+    shape: SpatialShape = ElevatedPanelDefaults.shape,
+    pose: Pose? = null,
+    content: @Composable () -> Unit,
+) {
+    val parentView = LocalRootView.current ?: LocalView.current
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val parentPanelEntity = LocalPanelEntity.current ?: session.mainPanelEntity
+    val density = LocalDensity.current
+    var panelEntity by remember { mutableStateOf<PanelEntity?>(null) }
+    val meterSize = contentSize.toMeterSize(density)
+
+    val view = rememberComposeView {
+        CompositionLocalProvider(
+            LocalRootView provides parentView,
+            LocalPanelEntity provides panelEntity,
+        ) {
+            Box(Modifier.alpha(if (pose == null) 0.0f else 1.0f)) { content() }
+        }
+    }
+
+    DisposableEffect(Unit) {
+        panelEntity =
+            session.createPanelEntity(
+                view = view,
+                surfaceDimensionsPx = meterSize.toCorePixelDimensions(density),
+                dimensions = meterSize.toCoreMeterDimensions(),
+                name = "ElevatedSurface:${view.id}",
+            )
+        onDispose {
+            panelEntity?.dispose()
+            panelEntity = null
+        }
+    }
+
+    LaunchedEffect(pose) {
+        if (pose != null) {
+            panelEntity?.setPose(pose)
+        }
+    }
+
+    LaunchedEffect(contentSize) {
+        val width = contentSize.width.toFloat()
+        val height = contentSize.height.toFloat()
+
+        panelEntity?.setSize(Dimensions(width = width, height = height, depth = 0f))
+        if (shape is SpatialRoundedCornerShape) {
+            panelEntity?.setCornerRadius(
+                Meter.fromPixel(shape.computeCornerRadius(width, height, density), density).value
+            )
+        }
+    }
+
+    LaunchedEffect(parentPanelEntity) { panelEntity?.setParent(parentPanelEntity) }
+}
+
+/** A [Position] based on [Meter]s. */
+internal data class MeterPosition(
+    val x: Meter = 0.meters,
+    val y: Meter = 0.meters,
+    val z: Meter = 0.meters,
+) {
+    /**
+     * Adds this [MeterPosition] to the [other] one.
+     *
+     * @param other the other [MeterPosition] to add.
+     * @return a new [MeterPosition] representing the sum of the two positions.
+     */
+    public operator fun plus(other: MeterPosition) =
+        MeterPosition(x = x + other.x, y = y + other.y, z = z + other.z)
+
+    fun toVector3() = Vector3(x = x.toM(), y = y.toM(), z = z.toM())
+}
+
+/** Represents a 3D size using [Meter] units. */
+internal data class MeterSize(
+    public val width: Meter = 0.meters,
+    public val height: Meter = 0.meters,
+    public val depth: Meter = 0.meters,
+)
+
+private fun IntSize.toMeterSize(density: Density) =
+    MeterSize(
+        Meter.fromPixel(width.toFloat(), density),
+        Meter.fromPixel(height.toFloat(), density),
+        0.meters,
+    )
+
+private fun MeterSize.toCoreMeterDimensions() = Dimensions(width.toM(), height.toM(), depth.toM())
+
+// TODO(b/355735174) Update to PixelDimensions when SceneCore provides a proper API surface.
+private fun MeterSize.toCorePixelDimensions(density: Density) =
+    Dimensions(width.toPx(density), height.toPx(density), depth.toPx(density))
+
+internal val View.size
+    get() = IntSize(width, height)
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Orbiter.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Orbiter.kt
new file mode 100644
index 0000000..3c24d7a
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Orbiter.kt
@@ -0,0 +1,497 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.platform.LocalDialogManager
+import androidx.xr.compose.platform.LocalPanelEntity
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+import androidx.xr.compose.spatial.EdgeOffset.Companion.outer
+import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
+import androidx.xr.compose.subspace.layout.SpatialShape
+import androidx.xr.scenecore.PixelDimensions
+
+/** Contains default values used by Orbiters. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object OrbiterDefaults {
+
+    /** Default shape for an Orbiter. */
+    public val shape: SpatialShape = SpatialRoundedCornerShape(ZeroCornerSize)
+
+    /** Default settings for an Orbiter */
+    public val orbiterSettings: OrbiterSettings = OrbiterSettings()
+}
+
+/**
+ * Settings for an Orbiter.
+ *
+ * @property shouldRenderInNonSpatial controls whether the orbiter content should be rendered in the
+ *   normal flow in non-spatial environments. If `true`, the content is rendered normally;
+ *   otherwise, it's removed from the flow.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OrbiterSettings(
+    @get:JvmName("shouldRenderInNonSpatial") public val shouldRenderInNonSpatial: Boolean = true
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is OrbiterSettings) return false
+
+        if (shouldRenderInNonSpatial != other.shouldRenderInNonSpatial) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        return shouldRenderInNonSpatial.hashCode()
+    }
+
+    override fun toString(): String {
+        return "OrbiterSettings(shouldRenderInNonSpatial=$shouldRenderInNonSpatial)"
+    }
+
+    public fun copy(
+        shouldRenderInNonSpatial: Boolean = this.shouldRenderInNonSpatial
+    ): OrbiterSettings = OrbiterSettings(shouldRenderInNonSpatial = shouldRenderInNonSpatial)
+}
+
+/**
+ * A composable that creates an orbiter along the top or bottom edges of a view.
+ *
+ * @param position The edge of the orbiter. Use [OrbiterEdge.Top] or [OrbiterEdge.Bottom].
+ * @param offset The offset of the orbiter based on the outer edge of the orbiter.
+ * @param alignment The alignment of the orbiter. Use [Alignment.CenterHorizontally] or
+ *   [Alignment.Start] or [Alignment.End].
+ * @param settings The settings for the orbiter.
+ * @param shape The shape of this Orbiter when it is rendered in 3D space.
+ * @param content The content of the orbiter.
+ *
+ * Example:
+ * ```
+ * Orbiter(position = OrbiterEdge.Top, offset = 10.dp) {
+ *   Text("This is a top edge Orbiter")
+ * }
+ * ```
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Orbiter(
+    position: OrbiterEdge.Horizontal,
+    offset: Dp = 0.dp,
+    alignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+    settings: OrbiterSettings = OrbiterDefaults.orbiterSettings,
+    shape: SpatialShape = OrbiterDefaults.shape,
+    content: @Composable () -> Unit,
+) {
+    Orbiter(
+        OrbiterData(
+            position = position,
+            horizontalAlignment = alignment,
+            offset = outer(offset),
+            settings = settings,
+            shape = shape,
+            content = content,
+        )
+    )
+}
+
+/**
+ * A composable that creates an orbiter along the top or bottom edges of a view.
+ *
+ * @param position The edge of the orbiter. Use [OrbiterEdge.Top] or [OrbiterEdge.Bottom].
+ * @param offset The offset of the orbiter based on the inner or outer edge of the orbiter. Use
+ *   [EdgeOffset.outer] to create an [EdgeOffset] aligned to the outer edge of the orbiter or
+ *   [innerEdge] or [EdgeOffset.overlap] to create an [EdgeOffset] aligned to the inner edge of the
+ *   orbiter.
+ * @param alignment The alignment of the orbiter. Use [Alignment.CenterHorizontally] or
+ *   [Alignment.Start] or [Alignment.End].
+ * @param settings The settings for the orbiter.
+ * @param shape The shape of this Orbiter when it is rendered in 3D space.
+ * @param content The content of the orbiter.
+ *
+ * Example:
+ * ```
+ * Orbiter(position = OrbiterEdge.Top, offset = outer(10.dp)) {
+ *   Text("This is a top edge Orbiter")
+ * }
+ * ```
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Orbiter(
+    position: OrbiterEdge.Horizontal,
+    offset: EdgeOffset,
+    alignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+    settings: OrbiterSettings = OrbiterDefaults.orbiterSettings,
+    shape: SpatialShape = OrbiterDefaults.shape,
+    content: @Composable () -> Unit,
+) {
+    Orbiter(
+        OrbiterData(
+            position = position,
+            horizontalAlignment = alignment,
+            offset = offset,
+            settings = settings,
+            shape = shape,
+            content = content,
+        )
+    )
+}
+
+/**
+ * A composable that creates an orbiter along the start or end edges of a view.
+ *
+ * @param position The edge of the orbiter. Use [OrbiterEdge.Start] or [OrbiterEdge.End].
+ * @param offset The offset of the orbiter based on the outer edge of the orbiter.
+ * @param alignment The alignment of the orbiter. Use [Alignment.CenterVertically] or
+ *   [Alignment.Top] or [Alignment.Bottom].
+ * @param settings The settings for the orbiter.
+ * @param shape The shape of this Orbiter when it is rendered in 3D space.
+ * @param content The content of the orbiter.
+ *
+ * Example:
+ * ```
+ * Orbiter(position = OrbiterEdge.Start, offset = 10.dp) {
+ *   Text("This is a start edge Orbiter")
+ * }
+ * ```
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Orbiter(
+    position: OrbiterEdge.Vertical,
+    offset: Dp = 0.dp,
+    alignment: Alignment.Vertical = Alignment.CenterVertically,
+    settings: OrbiterSettings = OrbiterDefaults.orbiterSettings,
+    shape: SpatialShape = OrbiterDefaults.shape,
+    content: @Composable () -> Unit,
+) {
+    Orbiter(
+        OrbiterData(
+            position = position,
+            verticalAlignment = alignment,
+            offset = outer(offset),
+            settings = settings,
+            shape = shape,
+            content = content,
+        )
+    )
+}
+
+/**
+ * A composable that creates an orbiter along the start or end edges of a view.
+ *
+ * @param position The edge of the orbiter. Use [OrbiterEdge.Start] or [OrbiterEdge.End].
+ * @param offset The offset of the orbiter based on the inner or outer edge of the orbiter. Use
+ *   [EdgeOffset.outer] to create an [EdgeOffset] aligned to the outer edge of the orbiter or
+ *   [innerEdge] or [EdgeOffset.overlap] to create an [EdgeOffset] aligned to the inner edge of the
+ *   orbiter.
+ * @param alignment The alignment of the orbiter. Use [Alignment.CenterVertically] or
+ *   [Alignment.Top] or [Alignment.Bottom].
+ * @param settings The settings for the orbiter.
+ * @param shape The shape of this Orbiter when it is rendered in 3D space.
+ * @param content The content of the orbiter.
+ *
+ * Example:
+ * ```
+ * Orbiter(position = OrbiterEdge.Start, offset = outer(10.dp)) {
+ *   Text("This is a start edge Orbiter")
+ * }
+ * ```
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Orbiter(
+    position: OrbiterEdge.Vertical,
+    offset: EdgeOffset,
+    alignment: Alignment.Vertical = Alignment.CenterVertically,
+    settings: OrbiterSettings = OrbiterDefaults.orbiterSettings,
+    shape: SpatialShape = OrbiterDefaults.shape,
+    content: @Composable () -> Unit,
+) {
+    Orbiter(
+        OrbiterData(
+            position = position,
+            verticalAlignment = alignment,
+            offset = offset,
+            settings = settings,
+            shape = shape,
+            content = content,
+        )
+    )
+}
+
+@Composable
+private fun Orbiter(data: OrbiterData) {
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
+        PositionedOrbiter(data)
+    } else if (data.settings.shouldRenderInNonSpatial) {
+        data.content()
+    }
+}
+
+@Composable
+internal fun PositionedOrbiter(data: OrbiterData) {
+    val view = LocalView.current
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val panelEntity = LocalPanelEntity.current ?: session.mainPanelEntity
+    var panelSize by remember { mutableStateOf(panelEntity.getPixelDimensions()) }
+    var contentSize: IntSize? by remember { mutableStateOf(null) }
+    val dialogManager = LocalDialogManager.current
+
+    DisposableEffect(view) {
+        val listener =
+            View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+                panelSize = panelEntity.getPixelDimensions()
+            }
+        view.addOnLayoutChangeListener(listener)
+        onDispose { view.removeOnLayoutChangeListener(listener) }
+    }
+
+    ElevatedPanel(
+        spatialElevationLevel = SpatialElevationLevel.Level1,
+        contentSize = contentSize ?: IntSize.Zero,
+        contentOffset = contentSize?.let { data.calculateOffset(panelSize, it) },
+        shape = data.shape,
+    ) {
+        Box(
+            modifier =
+                Modifier.constrainTo(Constraints(0, panelSize.width, 0, panelSize.height))
+                    .onSizeChanged { contentSize = it }
+        ) {
+            data.content()
+        }
+        Box(
+            modifier =
+                Modifier.fillMaxSize()
+                    .then(
+                        if (dialogManager.isSpatialDialogActive.value) {
+                            Modifier.background(Color.Black.copy(alpha = 0.2f)).pointerInput(Unit) {
+                                detectTapGestures {
+                                    dialogManager.isSpatialDialogActive.value = false
+                                }
+                            }
+                        } else {
+                            Modifier
+                        }
+                    )
+        ) {}
+    }
+}
+
+/** An enum that represents the edges of a view where an orbiter can be placed. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public sealed interface OrbiterEdge {
+    @JvmInline
+    public value class Horizontal private constructor(private val value: Int) : OrbiterEdge {
+        public companion object {
+            public val Top: Horizontal = Horizontal(0)
+            public val Bottom: Horizontal = Horizontal(1)
+        }
+    }
+
+    /** Represents vertical edges (start or end). */
+    @JvmInline
+    public value class Vertical private constructor(private val value: Int) : OrbiterEdge {
+        public companion object {
+            public val Start: Vertical = Vertical(0)
+            public val End: Vertical = Vertical(1)
+        }
+    }
+
+    public companion object {
+        /** The top edge. */
+        public val Top: Horizontal = Horizontal.Top
+        /** The bottom edge. */
+        public val Bottom: Horizontal = Horizontal.Bottom
+        /** The start edge. */
+        public val Start: Vertical = Vertical.Start
+        /** The end edge. */
+        public val End: Vertical = Vertical.End
+    }
+}
+
+/** Represents the type of offset used for positioning an orbiter. */
+@JvmInline
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public value class OrbiterOffsetType private constructor(private val value: Int) {
+    public companion object {
+        /** Indicates that the offset is relative to the outer edge of the orbiter. */
+        public val OuterEdge: OrbiterOffsetType = OrbiterOffsetType(0)
+        /** Indicates that the offset is relative to the inner edge of the orbiter. */
+        public val InnerEdge: OrbiterOffsetType = OrbiterOffsetType(1)
+    }
+}
+
+/**
+ * Represents the offset of an orbiter from the main panel.
+ *
+ * @property amount the magnitude of the offset in pixels.
+ * @property type the type of offset ([OrbiterOffsetType.OuterEdge] or
+ *   [OrbiterOffsetType.InnerEdge]).
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class EdgeOffset
+internal constructor(public val amount: Float, public val type: OrbiterOffsetType) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is EdgeOffset) return false
+
+        if (amount != other.amount) return false
+        if (type != other.type) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = amount.hashCode()
+        result = 31 * result + type.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "EdgeOffset(amount=$amount, type=$type)"
+    }
+
+    public fun copy(amount: Float = this.amount, type: OrbiterOffsetType = this.type): EdgeOffset =
+        EdgeOffset(amount = amount, type = type)
+
+    public companion object {
+        /**
+         * Creates an [EdgeOffset] representing an offset from the outer edge of an orbiter.
+         *
+         * An offset that represents the offset of an orbiter from the main panel relative to the
+         * outer edge of the orbiter. In outer edge alignment, the outer edge of the orbiter will be
+         * [offset] distance away from the edge of the main panel.
+         *
+         * @param offset the offset value in [Dp].
+         * @return an [EdgeOffset] with the specified offset and type [OrbiterOffsetType.OuterEdge].
+         */
+        @Composable
+        public fun outer(offset: Dp): EdgeOffset =
+            with(LocalDensity.current) { EdgeOffset(offset.toPx(), OrbiterOffsetType.OuterEdge) }
+
+        /**
+         * Creates an [EdgeOffset] representing an offset from the inner edge of an orbiter.
+         *
+         * An offset that represents the offset of an orbiter from the main panel relative to the
+         * inner edge of the orbiter. In inner edge alignment, the inner edge of the orbiter will be
+         * [offset] distance away from the edge of the main panel.
+         *
+         * @param offset the offset value in [Dp].
+         * @return an [EdgeOffset] with the specified offset and type [OrbiterOffsetType.InnerEdge].
+         */
+        @Composable
+        public fun inner(offset: Dp): EdgeOffset =
+            with(LocalDensity.current) { EdgeOffset(offset.toPx(), OrbiterOffsetType.InnerEdge) }
+
+        /**
+         * Creates an [EdgeOffset] representing an overlap of an orbiter into the main panel
+         * relative to the inner edge of the orbiter.
+         *
+         * In overlap alignment, the inner edge of the orbiter will be [offset] distance inset into
+         * the edge of the main panel.
+         *
+         * @param offset the amount of overlap, specified in [Dp].
+         * @return an [EdgeOffset] with the [offset]'s pixel value and
+         *   [OrbiterOffsetType.InnerEdge].
+         */
+        @Composable
+        public fun overlap(offset: Dp): EdgeOffset =
+            with(LocalDensity.current) { EdgeOffset(-offset.toPx(), OrbiterOffsetType.InnerEdge) }
+    }
+}
+
+internal data class OrbiterData(
+    public val position: OrbiterEdge,
+    public val verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
+    public val horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+    public val offset: EdgeOffset,
+    public val settings: OrbiterSettings = OrbiterDefaults.orbiterSettings,
+    public val content: @Composable () -> Unit,
+    public val shape: SpatialShape,
+)
+
+/**
+ * Calculates the offset that should be applied to the orbiter given its settings, the panel size,
+ * and the size of the orbiter content.
+ */
+private fun OrbiterData.calculateOffset(viewSize: PixelDimensions, contentSize: IntSize): Offset {
+    if (position is OrbiterEdge.Vertical) {
+        val y = verticalAlignment.align(contentSize.height, viewSize.height)
+
+        val xOffset =
+            when (offset.type) {
+                OrbiterOffsetType.OuterEdge -> -offset.amount
+                OrbiterOffsetType.InnerEdge -> -contentSize.width - offset.amount
+                else -> error("Unexpected OrbiterOffsetType: ${offset.type}")
+            }
+
+        val x =
+            when (position) {
+                OrbiterEdge.Start -> xOffset
+                OrbiterEdge.End -> viewSize.width - contentSize.width - xOffset
+                else -> error("Unexpected OrbiterEdge: $position")
+            }
+        return Offset(x, y.toFloat())
+    } else {
+        // It should be fine to use LTR layout direction here since we can use placeRelative to
+        // adjust
+        val x = horizontalAlignment.align(contentSize.width, viewSize.width, LayoutDirection.Ltr)
+
+        val yOffset =
+            when (offset.type) {
+                OrbiterOffsetType.OuterEdge -> -offset.amount
+                OrbiterOffsetType.InnerEdge -> -contentSize.height - offset.amount
+                else -> error("Unexpected OrbiterOffsetType: ${offset.type}")
+            }
+
+        val y =
+            when (position) {
+                OrbiterEdge.Top -> yOffset
+                OrbiterEdge.Bottom -> viewSize.height - contentSize.height - yOffset
+                else -> error("Unexpected OrbiterEdge: $position")
+            }
+        return Offset(x.toFloat(), y)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/OutsideInputHandler.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/OutsideInputHandler.kt
new file mode 100644
index 0000000..dd7fd87
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/OutsideInputHandler.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.MotionEvent
+import android.view.View
+import android.view.WindowManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.RememberObserver
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.updateLayoutParams
+import kotlin.math.roundToInt
+
+/** Handle clicks outside of the parent panel. */
+@Composable
+internal fun OutsideInputHandler(enabled: Boolean = true, onOutsideInput: () -> Unit) {
+    if (enabled) {
+        val view = LocalView.current
+        val currentOnOutsideInput = rememberUpdatedState(onOutsideInput)
+        remember(view) { InputCaptureView(view, currentOnOutsideInput) }
+    }
+}
+
+/**
+ * A View that captures input events and forwards pointer and touch events to a target view.
+ *
+ * This allows us to detect when touch events happen outside of the bounds of the target view.
+ *
+ * This default View constructor is used by tooling (as per a warning in Android Studio). In
+ * practice, use the constructor that takes a targetView and onOutsideInput.
+ */
+private class InputCaptureView private constructor(context: Context) :
+    View(context), RememberObserver, View.OnLayoutChangeListener {
+    constructor(targetView: View, onOutsideInput: State<() -> Unit>) : this(targetView.context) {
+        this.targetView = targetView
+        this.onOutsideInput = onOutsideInput
+    }
+
+    init {
+        // Assign the layout parameters for this view. See documentation for more details:
+        // https://developer.android.com/reference/android/view/View#setLayoutParams(android.view.ViewGroup.LayoutParams)
+        layoutParams =
+            WindowManager.LayoutParams().apply {
+                type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
+                flags = WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
+            }
+    }
+
+    private val windowManager: WindowManager =
+        context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+
+    private var targetView: View? = null
+        set(value) {
+            if (field != value && value != null) {
+                field?.removeOnLayoutChangeListener(this)
+                value.addOnLayoutChangeListener(this)
+                updateLayoutParams<WindowManager.LayoutParams> {
+                    // Get the Window token from the parent view
+                    token = value.applicationWindowToken
+
+                    // Match the size of the target view.
+                    width = value.width
+                    height = value.height
+                }
+                if (isAttachedToWindow) {
+                    windowManager.updateViewLayout(this, layoutParams)
+                }
+            }
+            field = value
+        }
+
+    private var onOutsideInput: State<() -> Unit>? = null
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun onTouchEvent(event: MotionEvent?): Boolean {
+        if (event == null || targetView == null) {
+            return super.onTouchEvent(event)
+        }
+        if (isMotionEventOutsideTargetView(event)) {
+            onOutsideInput?.value?.invoke()
+        } else {
+            // This click is inside of the target view bounds, so dispatch it to the target view.
+            targetView?.dispatchTouchEvent(event)
+        }
+        return true
+    }
+
+    private fun isMotionEventOutsideTargetView(event: MotionEvent): Boolean {
+        val view = this.targetView ?: return true
+        // If the action is ACTION_DOWN, we need to check if the touch event is outside of the view
+        // bounds.
+        if (event.action == MotionEvent.ACTION_DOWN) {
+            return event.x.roundToInt() !in view.left..view.right ||
+                event.y.roundToInt() !in view.top..view.bottom
+        }
+        return event.action == MotionEvent.ACTION_OUTSIDE
+    }
+
+    override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
+        targetView?.dispatchGenericMotionEvent(event)
+        return true
+    }
+
+    override fun onAbandoned() {
+        // Do nothing. Nothing is set up until onRemembered is called.
+    }
+
+    override fun onForgotten() {
+        targetView?.removeOnLayoutChangeListener(this)
+        windowManager.removeView(this)
+    }
+
+    override fun onRemembered() {
+        windowManager.addView(this, layoutParams)
+    }
+
+    /**
+     * Update the layout parameters of this view to match the size of the [targetView].
+     *
+     * This is called when the [targetView] is laid out.
+     */
+    override fun onLayoutChange(
+        v: View?,
+        left: Int,
+        top: Int,
+        right: Int,
+        bottom: Int,
+        oldLeft: Int,
+        oldTop: Int,
+        oldRight: Int,
+        oldBottom: Int,
+    ) {
+        updateLayoutParams<WindowManager.LayoutParams> {
+            width = right - left
+            height = bottom - top
+        }
+        if (isAttachedToWindow) {
+            windowManager.updateViewLayout(this, layoutParams)
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/RememberCalculatePose.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/RememberCalculatePose.kt
new file mode 100644
index 0000000..d159c06
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/RememberCalculatePose.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.xr.compose.unit.Meter
+import androidx.xr.compose.unit.Meter.Companion.meters
+import androidx.xr.runtime.math.Pose
+
+/** Calculate a [Pose] in 3D space based on the relative offset within the 2D space of a Panel. */
+@Composable
+internal fun rememberCalculatePose(
+    contentOffset: Offset,
+    parentViewSize: IntSize,
+    contentSize: IntSize,
+    zDepth: Float = 0f,
+): Pose {
+    val density = LocalDensity.current
+    return remember(contentOffset, parentViewSize, contentSize, zDepth) {
+        val meterPosition =
+            contentOffset.toMeterPosition(parentViewSize, contentSize, density) +
+                MeterPosition(z = zDepth.meters)
+        Pose(translation = meterPosition.toVector3())
+    }
+}
+
+/**
+ * Resolves the coordinate systems between 2D app pixel space and 3D meter space. In 2d space, views
+ * and composables are anchored at the top left corner; however, in 3D space they are anchored at
+ * the center. This fixes that by adjusting for the space size and the content's size so they are
+ * anchored in the top left corner in 3D space.
+ *
+ * This conversion requires that [density] be specified.
+ */
+private fun Offset.toMeterPosition(
+    parentViewSize: IntSize,
+    contentSize: IntSize,
+    density: Density,
+) =
+    MeterPosition(
+        Meter.fromPixel(x.scale(contentSize.width, parentViewSize.width), density),
+        Meter.fromPixel(-y.scale(contentSize.height, parentViewSize.height), density),
+    )
+
+private fun Float.scale(size: Int, space: Int) = this + (size - space) / 2.0f
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialDialog.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialDialog.kt
new file mode 100644
index 0000000..d62b0e0
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialDialog.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.annotation.RestrictTo
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.xr.compose.platform.LocalDialogManager
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+import androidx.xr.compose.unit.Meter
+import androidx.xr.compose.unit.Meter.Companion.meters
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.Session
+import kotlinx.coroutines.launch
+
+/**
+ * Properties for configuring a [SpatialDialog].
+ *
+ * @property dismissOnBackPress whether the dialog should be dismissed when the device's back button
+ *   is pressed. Defaults to `true`.
+ * @property dismissOnClickOutside whether the dialog should be dismissed when the user touches
+ *   outside of it. Defaults to `true`.
+ * @property usePlatformDefaultWidth whether the dialog should use the platform's default width.
+ *   Defaults to `true`.
+ * @property restingLevelAnimationSpec the animation specification for the resting level.
+ * @property spatialElevationLevel the elevation level of the dialog. Defaults to
+ *   [SpatialElevationLevel.DialogDefault].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialDialogProperties(
+    @get:Suppress("GetterSetterNames") public val dismissOnBackPress: Boolean = true,
+    @get:Suppress("GetterSetterNames") public val dismissOnClickOutside: Boolean = true,
+    @get:Suppress("GetterSetterNames") public val usePlatformDefaultWidth: Boolean = true,
+    public val restingLevelAnimationSpec: FiniteAnimationSpec<Float> = spring(),
+    public val spatialElevationLevel: SpatialElevationLevel = SpatialElevationLevel.DialogDefault,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SpatialDialogProperties) return false
+
+        if (dismissOnBackPress != other.dismissOnBackPress) return false
+        if (dismissOnClickOutside != other.dismissOnClickOutside) return false
+        if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false
+        if (restingLevelAnimationSpec != other.restingLevelAnimationSpec) return false
+        if (spatialElevationLevel != other.spatialElevationLevel) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = dismissOnBackPress.hashCode()
+        result = 31 * result + dismissOnClickOutside.hashCode()
+        result = 31 * result + usePlatformDefaultWidth.hashCode()
+        result = 31 * result + restingLevelAnimationSpec.hashCode()
+        result = 31 * result + spatialElevationLevel.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "SpatialDialogProperties(dismissOnBackPress=$dismissOnBackPress, dismissOnClickOutside=$dismissOnClickOutside, usePlatformDefaultWidth=$usePlatformDefaultWidth, restingLevelAnimationSpec=$restingLevelAnimationSpec, spatialElevationLevel=$spatialElevationLevel)"
+    }
+
+    public fun copy(
+        dismissOnBackPress: Boolean = this.dismissOnBackPress,
+        dismissOnClickOutside: Boolean = this.dismissOnClickOutside,
+        usePlatformDefaultWidth: Boolean = this.usePlatformDefaultWidth,
+        restingLevelAnimationSpec: FiniteAnimationSpec<Float> = this.restingLevelAnimationSpec,
+        spatialElevationLevel: SpatialElevationLevel = this.spatialElevationLevel,
+    ): SpatialDialogProperties =
+        SpatialDialogProperties(
+            dismissOnBackPress = dismissOnBackPress,
+            dismissOnClickOutside = dismissOnClickOutside,
+            usePlatformDefaultWidth = usePlatformDefaultWidth,
+            restingLevelAnimationSpec = restingLevelAnimationSpec,
+            spatialElevationLevel = spatialElevationLevel,
+        )
+}
+
+private fun SpatialDialogProperties.toBaseDialogProperties() =
+    DialogProperties(
+        dismissOnClickOutside = dismissOnClickOutside,
+        dismissOnBackPress = dismissOnBackPress,
+        usePlatformDefaultWidth = usePlatformDefaultWidth,
+    )
+
+/**
+ * [SpatialDialog] is a dialog that is elevated above the activity.
+ *
+ * @param onDismissRequest a callback to be invoked when the dialog should be dismissed.
+ * @param properties the dialog properties.
+ * @param content the content of the dialog.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialDialog(
+    onDismissRequest: () -> Unit,
+    properties: SpatialDialogProperties = SpatialDialogProperties(),
+    content: @Composable () -> Unit,
+) {
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
+        LayoutSpatialDialog(onDismissRequest, properties, content)
+    } else {
+        Dialog(
+            onDismissRequest = onDismissRequest,
+            properties = properties.toBaseDialogProperties(),
+            content = content,
+        )
+    }
+}
+
+@Composable
+private fun LayoutSpatialDialog(
+    onDismissRequest: () -> Unit,
+    properties: SpatialDialogProperties = SpatialDialogProperties(),
+    content: @Composable () -> Unit,
+) {
+    val view = LocalRootView.current ?: LocalView.current
+    val scope = rememberCoroutineScope()
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    // Start elevation at Level0 to prevent effects where the dialog flashes behind its parent.
+    var spatialElevationLevel by remember { mutableStateOf(SpatialElevationLevel.Level0) }
+    val dialogManager = LocalDialogManager.current
+
+    DisposableEffect(Unit) {
+        scope.launch {
+            animate(
+                initialValue = SpatialElevationLevel.ActivityDefault.level,
+                targetValue = -properties.spatialElevationLevel.level,
+                animationSpec = properties.restingLevelAnimationSpec,
+            ) { value, _ ->
+                session.setActivitySpaceZDepth(value.meters)
+            }
+        }
+        dialogManager.isSpatialDialogActive.value = true
+        onDispose {
+            session.resetActivitySpaceZDepth()
+            dialogManager.isSpatialDialogActive.value = false
+        }
+    }
+
+    LaunchedEffect(Unit) { spatialElevationLevel = properties.spatialElevationLevel }
+
+    LaunchedEffect(dialogManager.isSpatialDialogActive.value) {
+        if (!dialogManager.isSpatialDialogActive.value) {
+            onDismissRequest()
+        }
+    }
+
+    // Paint the scrim on the parent panel and capture dismiss events.
+    Dialog(
+        onDismissRequest = {
+            scope.launch {
+                animate(
+                    initialValue = -properties.spatialElevationLevel.level,
+                    targetValue = SpatialElevationLevel.ActivityDefault.level,
+                    animationSpec = properties.restingLevelAnimationSpec,
+                ) { value, _ ->
+                    session.setActivitySpaceZDepth(value.meters)
+                }
+            }
+            dialogManager.isSpatialDialogActive.value = false
+        },
+        properties = properties.toBaseDialogProperties(),
+    ) {
+        // We need a very small (non-zero) content to fill the remaining space with the scrim.
+        Spacer(Modifier.size(1.dp))
+    }
+
+    var contentSize by remember { mutableStateOf(view.size) }
+
+    val zDepth by
+        updateTransition(targetState = spatialElevationLevel, label = "restingLevelTransition")
+            .animateFloat(
+                transitionSpec = { properties.restingLevelAnimationSpec },
+                label = "zDepth"
+            ) { state ->
+                state.level
+            }
+
+    ElevatedPanel(
+        contentSize = contentSize,
+        pose = Pose(translation = MeterPosition(z = zDepth.meters).toVector3()),
+    ) {
+        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            Box(modifier = Modifier.onSizeChanged { contentSize = it }) { content() }
+        }
+    }
+}
+
+private fun Session.setActivitySpaceZDepth(value: Meter) {
+    activitySpace.setPose(Pose(translation = Vector3(0f, 0f, value.toM())))
+}
+
+private fun Session.resetActivitySpaceZDepth() {
+    setActivitySpaceZDepth(SpatialElevationLevel.ActivityDefault.level.meters)
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevation.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevation.kt
new file mode 100644
index 0000000..6228322
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevation.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+
+/**
+ * Composable that creates a panel in 3D space when spatialization is enabled.
+ *
+ * [SpatialElevation] elevates content in-place. It uses the source position and constraints to
+ * determine the size and placement of the elevated panel while reserving space for the original
+ * element within the layout.
+ *
+ * In non-spatial environments, the content is rendered normally without elevation.
+ *
+ * @param spatialElevationLevel the desired elevation level for the panel in spatial environments.
+ * @param content the composable content to be displayed within the elevated panel.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialElevation(
+    spatialElevationLevel: SpatialElevationLevel = SpatialElevationLevel.Level0,
+    content: @Composable () -> Unit,
+) {
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
+        LayoutSpatialElevation(spatialElevationLevel, content)
+    } else {
+        content()
+    }
+}
+
+@Composable
+private fun LayoutSpatialElevation(
+    spatialElevationLevel: SpatialElevationLevel,
+    content: @Composable () -> Unit,
+) {
+    val bufferPadding = 1.dp
+    val bufferPaddingPx = with(LocalDensity.current) { bufferPadding.toPx() }
+    var contentSize by remember { mutableStateOf(IntSize.Zero) }
+    var contentOffset: Offset? by remember { mutableStateOf(null) }
+
+    // Reserve space for the content in the original view.
+    with(LocalDensity.current) {
+        Spacer(
+            Modifier.size(contentSize.width.toDp(), contentSize.height.toDp())
+                .onGloballyPositioned { contentOffset = it.positionInRoot() }
+        )
+    }
+
+    // It is important to use BoxWithConstraints here because the Layout within the ElevatedPanel
+    // does
+    // not know the constraints of the parent view.
+    BoxWithConstraints {
+        ElevatedPanel(
+            spatialElevationLevel = spatialElevationLevel,
+            contentSize = contentSize,
+            contentOffset = contentOffset,
+        ) {
+            // This padding prevents visual aberrations due to stretched panels. The panel is still
+            // being stretched in those cases (which will affect input tracking), but it will not be
+            // visible to the user.
+            // TODO(b/333074376): Remove this padding when the underlying bug is fixed.
+            Box(
+                Modifier.constrainTo(constraints)
+                    .onSizeChanged {
+                        check(it.width > bufferPaddingPx * 2 && it.height > bufferPaddingPx * 2) {
+                            "Empty composables cannot be placed at a SpatialElevation. You may be trying" +
+                                " to use a Popup or Dialog with a SpatialElevation, which is not supported."
+                        }
+                        contentSize = it
+                    }
+                    .padding(bufferPadding)
+            ) {
+                content()
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevationLevel.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevationLevel.kt
new file mode 100644
index 0000000..60dabb4
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialElevationLevel.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.unit.toMeter
+
+/**
+ * Represents the resting elevation level for spatial elements.
+ *
+ * Elevation levels range from `Level0` (no elevation) to `Level5` (highest allowed elevation).
+ *
+ * NOTE: Level0 is not visually distinguishable from base-level content but is present to support
+ * smooth transitioning between elevation levels.
+ *
+ * Values are expressed in meters for consistency with spatial positioning.
+ */
+@JvmInline
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public value class SpatialElevationLevel internal constructor(public val level: Float) {
+    public companion object {
+        internal val ActivityDefault = SpatialElevationLevel(0f)
+        public val Level0: SpatialElevationLevel = SpatialElevationLevel(0.1.dp.toMeter().value)
+        public val Level1: SpatialElevationLevel = SpatialElevationLevel(15.dp.toMeter().value)
+        public val Level2: SpatialElevationLevel = SpatialElevationLevel(30.dp.toMeter().value)
+        public val Level3: SpatialElevationLevel = SpatialElevationLevel(60.dp.toMeter().value)
+        public val Level4: SpatialElevationLevel = SpatialElevationLevel(90.dp.toMeter().value)
+        public val Level5: SpatialElevationLevel = SpatialElevationLevel(120.dp.toMeter().value)
+        internal val DialogDefault = SpatialElevationLevel(125.dp.toMeter().value)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialPopup.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialPopup.kt
new file mode 100644
index 0000000..64be95a
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/SpatialPopup.kt
@@ -0,0 +1,369 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import android.graphics.Rect
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+
+/**
+ * [SpatialPopup] properties.
+ *
+ * @property focusable whether the popup is focusable. If `true`, it will handle IME events and key
+ *   presses (e.g., back button). Defaults to `false`.
+ * @property dismissOnBackPress whether the popup can be dismissed by pressing the back button
+ *   (Android) or escape key (desktop). Only effective if `focusable` is `true`. Defaults to `true`.
+ * @property dismissOnClickOutside whether the popup can be dismissed by clicking outside its
+ *   bounds. If true, clicking outside the popup will call onDismissRequest. Defaults to `true`.
+ * @property clippingEnabled whether to allow the popup window to extend beyond the screen
+ *   boundaries. Defaults to `true`. Setting this to false will allow windows to be accurately
+ *   positioned.
+ * @property spatialElevationLevel the resting level of the elevated popup. Defaults to
+ *   [SpatialElevationLevel.Level3].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialPopupProperties(
+    @get:Suppress("GetterSetterNames") public val focusable: Boolean = false,
+    @get:Suppress("GetterSetterNames") public val dismissOnBackPress: Boolean = true,
+    @get:Suppress("GetterSetterNames") public val dismissOnClickOutside: Boolean = true,
+    @get:Suppress("GetterSetterNames") public val clippingEnabled: Boolean = true,
+    public val spatialElevationLevel: SpatialElevationLevel = SpatialElevationLevel.Level3,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SpatialPopupProperties) return false
+
+        if (focusable != other.focusable) return false
+        if (dismissOnBackPress != other.dismissOnBackPress) return false
+        if (dismissOnClickOutside != other.dismissOnClickOutside) return false
+        if (clippingEnabled != other.clippingEnabled) return false
+        if (spatialElevationLevel != other.spatialElevationLevel) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = focusable.hashCode()
+        result = 31 * result + dismissOnBackPress.hashCode()
+        result = 31 * result + dismissOnClickOutside.hashCode()
+        result = 31 * result + clippingEnabled.hashCode()
+        result = 31 * result + spatialElevationLevel.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "SpatialPopupProperties(focusable=$focusable, dismissOnBackPress=$dismissOnBackPress, dismissOnClickOutside=$dismissOnClickOutside, clippingEnabled=$clippingEnabled, spatialElevationLevel=$spatialElevationLevel)"
+    }
+
+    public fun copy(
+        focusable: Boolean = this.focusable,
+        dismissOnBackPress: Boolean = this.dismissOnBackPress,
+        dismissOnClickOutside: Boolean = this.dismissOnClickOutside,
+        clippingEnabled: Boolean = this.clippingEnabled,
+        spatialElevationLevel: SpatialElevationLevel = this.spatialElevationLevel,
+    ): SpatialPopupProperties =
+        SpatialPopupProperties(
+            focusable = focusable,
+            dismissOnBackPress = dismissOnBackPress,
+            dismissOnClickOutside = dismissOnClickOutside,
+            clippingEnabled = clippingEnabled,
+            spatialElevationLevel = spatialElevationLevel,
+        )
+}
+
+private fun SpatialPopupProperties.toPopupProperties() =
+    PopupProperties(
+        focusable = focusable,
+        dismissOnBackPress = dismissOnBackPress,
+        dismissOnClickOutside = dismissOnClickOutside,
+        clippingEnabled = clippingEnabled,
+    )
+
+/**
+ * A composable that creates a panel in 3D space to hoist Popup based composables.
+ *
+ * @param alignment the alignment of the popup relative to its parent.
+ * @param offset An offset from the original aligned position of the popup. Offset respects the
+ *   Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
+ *   will be subtracted from it.
+ * @param onDismissRequest callback invoked when the user requests to dismiss the popup (e.g., by
+ *   clicking outside).
+ * @param properties [PopupProperties] configuration properties for further customization of this
+ *   popup's behavior.
+ * @param content the composable content to be displayed within the popup.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialPopup(
+    alignment: Alignment = Alignment.TopStart,
+    offset: IntOffset = IntOffset(0, 0),
+    onDismissRequest: (() -> Unit)? = null,
+    properties: SpatialPopupProperties = SpatialPopupProperties(),
+    content: @Composable () -> Unit,
+) {
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
+        LayoutSpatialPopup(
+            alignment = alignment,
+            offset = offset,
+            onDismissRequest = onDismissRequest,
+            properties = properties,
+            content = content,
+        )
+    } else {
+        Popup(
+            alignment = alignment,
+            offset = offset,
+            onDismissRequest = onDismissRequest,
+            properties = properties.toPopupProperties(),
+            content = content,
+        )
+    }
+}
+
+/**
+ * Composable that creates a panel in 3d space to hoist Popup based composables.
+ *
+ * @param alignment The alignment relative to the parent.
+ * @param offset An offset from the original aligned position of the popup. Offset respects the
+ *   Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
+ *   will be subtracted from it.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [PopupProperties] for further customization of this popup's behavior.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+private fun LayoutSpatialPopup(
+    alignment: Alignment = Alignment.TopStart,
+    offset: IntOffset = IntOffset(0, 0),
+    onDismissRequest: (() -> Unit)? = null,
+    properties: SpatialPopupProperties = SpatialPopupProperties(),
+    content: @Composable () -> Unit,
+) {
+    val popupPositioner =
+        remember(alignment, offset) { AlignmentOffsetPositionProvider(alignment, offset) }
+    LayoutSpatialPopup(
+        popupPositionProvider = popupPositioner,
+        onDismissRequest = onDismissRequest,
+        properties = properties,
+        content = content,
+    )
+}
+
+/**
+ * Opens a popup with the given content.
+ *
+ * The popup is positioned using a custom [popupPositionProvider].
+ *
+ * @param popupPositionProvider Provides the screen position of the popup.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [SpatialPopupProperties] for further customization of this popup's behavior.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+private fun LayoutSpatialPopup(
+    popupPositionProvider: PopupPositionProvider,
+    onDismissRequest: (() -> Unit)? = null,
+    properties: SpatialPopupProperties = SpatialPopupProperties(),
+    content: @Composable () -> Unit,
+) {
+    val restingLevel by remember { mutableStateOf(properties.spatialElevationLevel) }
+    var contentSize: IntSize by remember { mutableStateOf(IntSize.Zero) }
+    var parentLayoutDirection = LocalLayoutDirection.current
+    var anchorBounds by remember { mutableStateOf(IntRect.Zero) }
+    val fullScreenRect = getWindowVisibleDisplayFrame()
+    val windowSize = IntSize(fullScreenRect.width(), fullScreenRect.height())
+
+    val popupOffset by remember {
+        derivedStateOf {
+            popupPositionProvider.calculatePosition(
+                anchorBounds,
+                windowSize,
+                parentLayoutDirection,
+                contentSize,
+            )
+        }
+    }
+
+    // The coordinates should be re-calculated on every layout to properly retrieve the absolute
+    // bounds for popup content offset calculation.
+    Layout(
+        content = {},
+        modifier =
+            Modifier.onGloballyPositioned { childCoordinates ->
+                val parentCoordinates = childCoordinates.parentLayoutCoordinates!!
+                val layoutSize = parentCoordinates.size
+                val position = parentCoordinates.positionInWindow()
+                val layoutPosition =
+                    IntOffset(position.x.fastRoundToInt(), position.y.fastRoundToInt())
+
+                anchorBounds = IntRect(layoutPosition, layoutSize)
+            },
+    ) { _, _ ->
+        parentLayoutDirection = layoutDirection
+        layout(0, 0) {}
+    }
+
+    ElevatedPanel(
+        spatialElevationLevel = restingLevel,
+        contentSize = contentSize,
+        contentOffset = Offset(popupOffset.x.toFloat(), popupOffset.y.toFloat()),
+    ) {
+        OutsideInputHandler(enabled = properties.dismissOnClickOutside) {
+            onDismissRequest?.invoke()
+        }
+        Box(
+            Modifier.constrainTo(
+                    Constraints(
+                        minWidth = 0,
+                        maxWidth = Constraints.Infinity,
+                        minHeight = 0,
+                        maxHeight = Constraints.Infinity,
+                    )
+                )
+                .onSizeChanged { contentSize = it }
+        ) {
+            content()
+        }
+    }
+}
+
+// Get the visible display Rect for the current window of the device
+@Composable
+private fun getWindowVisibleDisplayFrame(): Rect {
+    return Rect().apply { LocalView.current.getWindowVisibleDisplayFrame(this) }
+}
+
+/**
+ * Opens a popup with the given content.
+ *
+ * @param spatialElevationLevel the resting elevation level of the popup.
+ * @param content the composable content to be displayed within the popup, along with a callback
+ *   which is explicitly to be used for the [onGloballyPositioned] modifier of the Popup composable.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialPopup(
+    spatialElevationLevel: SpatialElevationLevel = SpatialElevationLevel.Level0,
+    content: @Composable (onGloballyPositioned: (LayoutCoordinates) -> Unit) -> Unit,
+) {
+    var contentSize: IntSize by remember { mutableStateOf(IntSize.Zero) }
+    var contentOffset by remember { mutableStateOf(Offset.Zero) }
+
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
+        ElevatedPanel(
+            spatialElevationLevel = spatialElevationLevel,
+            contentSize = contentSize,
+            contentOffset = contentOffset,
+        ) {
+            content { coordinates ->
+                contentSize = coordinates.size
+                contentOffset = coordinates.positionInRoot()
+            }
+        }
+    } else {
+        content {}
+    }
+}
+
+/** Calculates the position of a [Popup] on a screen. */
+@Immutable
+private interface PopupPositionProvider {
+    /**
+     * Calculates the position of a [Popup] on screen.
+     *
+     * The window size is useful in cases where the popup is meant to be positioned next to its
+     * anchor instead of inside of it. The size can be used to calculate available space around the
+     * parent to find a spot with enough clearance (e.g. when implementing a dropdown). Note that
+     * positioning the popup outside of the window bounds might prevent it from being visible.
+     *
+     * @param anchorBounds the bounds of the anchor layout relative to the window.
+     * @param windowSize The size of the window containing the anchor layout.
+     * @param layoutDirection The layout direction of the anchor layout.
+     * @param popupContentSize The size of the popup's content.
+     * @return The window relative position where the popup should be positioned.
+     */
+    fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize,
+    ): IntOffset
+}
+
+/** Calculates the position of a [Popup] on screen. */
+private class AlignmentOffsetPositionProvider(val alignment: Alignment, val offset: IntOffset) :
+    PopupPositionProvider {
+
+    /**
+     * Calculates the position of a [Popup] on screen.
+     *
+     * The window size is useful in cases where the popup is meant to be positioned next to its
+     * anchor instead of inside of it. The size can be used to calculate available space around the
+     * parent to find a spot with enough clearance (e.g. when implementing a dropdown). Note that
+     * positioning the popup outside of the window bounds might prevent it from being visible.
+     *
+     * @param anchorBounds The window relative bounds of the layout which this popup is anchored to.
+     * @param windowSize The size of the window containing the anchor layout.
+     * @param layoutDirection The layout direction of the anchor layout.
+     * @param popupContentSize The size of the popup's content.
+     * @return The window relative position where the popup should be positioned.
+     */
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize,
+    ): IntOffset {
+        val anchorAlignmentPoint = alignment.align(IntSize.Zero, anchorBounds.size, layoutDirection)
+        // Note the negative sign. Popup alignment point contributes negative offset.
+        val popupAlignmentPoint = -alignment.align(IntSize.Zero, popupContentSize, layoutDirection)
+        val resolvedUserOffset =
+            IntOffset(offset.x * (if (layoutDirection == LayoutDirection.Ltr) 1 else -1), offset.y)
+
+        return anchorBounds.topLeft +
+            anchorAlignmentPoint +
+            popupAlignmentPoint +
+            resolvedUserOffset
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Subspace.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Subspace.kt
new file mode 100644
index 0000000..6c56106
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/spatial/Subspace.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.activity.ComponentActivity
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.compositionLocalWithComputedDefaultOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCompositionContext
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.IntSize
+import androidx.xr.compose.platform.LocalPanelEntity
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+import androidx.xr.compose.platform.SpatialComposeScene
+import androidx.xr.compose.platform.getActivity
+import androidx.xr.compose.subspace.SpatialBox
+import androidx.xr.compose.subspace.SpatialBoxScope
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.layout.CoreContentlessEntity
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+
+private val LocalIsInApplicationSubspace: ProvidableCompositionLocal<Boolean> =
+    compositionLocalWithComputedDefaultOf {
+        LocalPanelEntity.currentValue != null
+    }
+
+/**
+ * Create a 3D area that the app can render spatial content into.
+ *
+ * If this is the topmost [Subspace] in the compose hierarchy then this will expand to fill all of
+ * the available space and will not be bound by its containing window.
+ *
+ * If this is nested within another [Subspace] then it will lay out its content in the X and Y
+ * directions according to the layout logic of its parent in 2D space. It will be constrained in the
+ * Z direction according to the constraints imposed by its containing [Subspace].
+ *
+ * This is a no-op and does not render anything in non-XR environments (i.e. Phone and Tablet).
+ *
+ * @param content The 3D content to render within this Subspace.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Subspace(content: @Composable @SubspaceComposable SpatialBoxScope.() -> Unit) {
+    val activity = LocalContext.current.getActivity()
+
+    // TODO(b/369446163) Test the case where a NestedSubspace could be created outside of a Panel.
+    if (LocalSpatialCapabilities.current.isSpatialUiEnabled && activity is ComponentActivity) {
+        if (LocalIsInApplicationSubspace.current) {
+            NestedSubspace(activity, content = content)
+        } else {
+            ApplicationSubspace(activity, content = content)
+        }
+    }
+}
+
+/**
+ * Create a Subspace that is rooted in the application space.
+ *
+ * This is used as the top-level [Subspace] within the context of the default task window. Nested
+ * Subspaces should use their nearest Panel that contains the [Subspace] to determine the sizing
+ * constraints and position of the [Subspace].
+ */
+@Composable
+private fun ApplicationSubspace(
+    activity: ComponentActivity,
+    content: @Composable @SubspaceComposable SpatialBoxScope.() -> Unit,
+) {
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val compositionContext = rememberCompositionContext()
+    val scene = remember {
+        SpatialComposeScene(
+            ownerActivity = activity,
+            jxrSession = session,
+            parentCompositionContext = compositionContext,
+        )
+    }
+
+    DisposableEffect(session) {
+        session.mainPanelEntity.setHidden(true)
+        onDispose { session.mainPanelEntity.setHidden(false) }
+    }
+
+    SideEffect {
+        scene.setContent {
+            CompositionLocalProvider(LocalIsInApplicationSubspace provides true) {
+                SpatialBox(content = content)
+            }
+        }
+    }
+
+    DisposableEffect(scene) { onDispose { scene.dispose() } }
+}
+
+@Composable
+private fun NestedSubspace(
+    activity: ComponentActivity,
+    content: @Composable @SubspaceComposable SpatialBoxScope.() -> Unit,
+) {
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val compositionContext = rememberCompositionContext()
+    val panelEntity = LocalPanelEntity.current
+    // The subspace root node will be owned and manipulated by the containing composition, we need a
+    // container that we can manipulate at the Subspace level in order to position the entire
+    // subspace
+    // properly.
+    val subspaceRootContainer = remember {
+        session.createEntity("SubspaceRootContainer").apply { setParent(panelEntity) }
+    }
+    val scene = remember {
+        val subspaceRoot =
+            session.createEntity("SubspaceRoot").apply { setParent(subspaceRootContainer) }
+        SpatialComposeScene(
+            ownerActivity = activity,
+            jxrSession = session,
+            parentCompositionContext = compositionContext,
+            rootEntity = CoreContentlessEntity(subspaceRoot),
+        )
+    }
+    var measuredSize by remember { mutableStateOf(IntVolumeSize.Zero) }
+    var contentOffset by remember { mutableStateOf(Offset.Zero) }
+    val pose =
+        rememberCalculatePose(
+            contentOffset,
+            LocalView.current.size,
+            measuredSize.run { IntSize(width, height) },
+        )
+
+    LaunchedEffect(pose) { subspaceRootContainer.setPose(pose) }
+    DisposableEffect(Unit) { onDispose { scene.dispose() } }
+
+    Layout(modifier = Modifier.onGloballyPositioned { contentOffset = it.positionInRoot() }) {
+        _,
+        constraints ->
+        scene.setContent {
+            SubspaceLayout(content = { SpatialBox(content = content) }) { measurables, _ ->
+                val placeables =
+                    measurables.map {
+                        it.measure(
+                            VolumeConstraints(
+                                minWidth = constraints.minWidth,
+                                maxWidth = constraints.maxWidth,
+                                minHeight = constraints.minHeight,
+                                maxHeight = constraints.maxHeight,
+                                // TODO(b/366564066) Nested Subspaces should get their depth
+                                // constraints from
+                                // the parent Subspace
+                                minDepth = 0,
+                                maxDepth = Int.MAX_VALUE,
+                            )
+                        )
+                    }
+                measuredSize =
+                    IntVolumeSize(
+                        width = placeables.maxOf { it.measuredWidth },
+                        height = placeables.maxOf { it.measuredHeight },
+                        depth = placeables.maxOf { it.measuredDepth },
+                    )
+                layout(measuredSize.width, measuredSize.height, measuredSize.depth) {
+                    placeables.forEach { it.place(Pose.Identity) }
+                }
+            }
+        }
+
+        layout(measuredSize.width, measuredSize.height) {}
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberComposeView.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberComposeView.kt
new file mode 100644
index 0000000..81dd734
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberComposeView.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCompositionContext
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.findViewTreeViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.findViewTreeSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import java.util.UUID
+
+/**
+ * Create a [ComposeView] that is applicable to the local context.
+ *
+ * This handles propagating the composition context and ensures that savable state is remembered.
+ */
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun rememberComposeView(content: @Composable () -> Unit): ComposeView {
+    val localId = rememberSaveable { UUID.randomUUID() }
+    val context = LocalContext.current
+    val parentView = LocalView.current
+    val compositionContext = rememberCompositionContext()
+
+    return remember {
+            ComposeView(context).apply {
+                id = android.R.id.content
+                setViewTreeLifecycleOwner(parentView.findViewTreeLifecycleOwner())
+                setViewTreeViewModelStoreOwner(parentView.findViewTreeViewModelStoreOwner())
+                setViewTreeSavedStateRegistryOwner(parentView.findViewTreeSavedStateRegistryOwner())
+                // Dispose of the Composition when the view's LifecycleOwner is destroyed
+                setParentCompositionContext(compositionContext)
+                // Set unique id for AbstractComposeView. This allows state restoration for the
+                // state
+                // defined inside the ElevatedSurface via rememberSaveable()
+                setTag(
+                    androidx.compose.ui.R.id.compose_view_saveable_id_tag,
+                    "ComposeView:$localId"
+                )
+
+                // Enable children to draw their shadow by not clipping them
+                clipChildren = false
+            }
+        }
+        .apply {
+            setContent(content)
+            DisposableEffect(this) { onDispose { disposeComposition() } }
+        }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberCoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberCoreEntity.kt
new file mode 100644
index 0000000..1d1107c
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RememberCoreEntity.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.subspace.layout.CoreContentlessEntity
+import androidx.xr.compose.subspace.layout.CorePanelEntity
+import androidx.xr.scenecore.BasePanelEntity
+import androidx.xr.scenecore.Entity
+import androidx.xr.scenecore.Session
+
+/**
+ * Creates a [CoreContentlessEntity] that is automatically disposed of when it leaves the
+ * composition.
+ */
+@Composable
+internal inline fun rememberCoreContentlessEntity(
+    crossinline entityFactory: @DisallowComposableCalls Session.() -> Entity
+): CoreContentlessEntity {
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val entity = remember { session.entityFactory() }
+
+    DisposableEffect(entity) { onDispose { entity.dispose() } }
+
+    return remember { CoreContentlessEntity(entity) }
+}
+
+/** Creates a [CorePanelEntity] that is automatically disposed of when it leaves the composition. */
+@Composable
+internal inline fun rememberCorePanelEntity(
+    crossinline entityFactory: @DisallowComposableCalls Session.() -> BasePanelEntity<*>
+): CorePanelEntity {
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+    val entity = remember { session.entityFactory() }
+
+    DisposableEffect(entity) { onDispose { entity.dispose() } }
+
+    return remember { CorePanelEntity(session, entity) }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnImpl.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnImpl.kt
new file mode 100644
index 0000000..9e3e1b5
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnImpl.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsModifier
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+
+internal class LayoutWeightElement(val weight: Float, val fill: Boolean) :
+    SubspaceModifierElement<LayoutWeightNode>() {
+    override fun create(): LayoutWeightNode = LayoutWeightNode(weight = weight, fill = fill)
+
+    override fun update(node: LayoutWeightNode) {
+        node.weight = weight
+        node.fill = fill
+    }
+
+    override fun hashCode(): Int {
+        var result = weight.hashCode()
+        result = 31 * result + fill.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherModifier = other as? LayoutWeightElement ?: return false
+        return weight == otherModifier.weight && fill == otherModifier.fill
+    }
+}
+
+internal class LayoutWeightNode(var weight: Float, var fill: Boolean) :
+    SubspaceModifier.Node(), ParentLayoutParamsModifier {
+    override fun adjustParams(params: ParentLayoutParamsAdjustable) {
+        if (params is RowColumnParentData) {
+            params.weight = weight
+            params.fill = fill
+        }
+    }
+}
+
+internal data class RowColumnParentData(var weight: Float = 0f, var fill: Boolean = true) :
+    ParentLayoutParamsAdjustable
+
+internal class RowColumnAlignElement(
+    val horizontalSpatialAlignment: SpatialAlignment.Horizontal? = null,
+    val verticalSpatialAlignment: SpatialAlignment.Vertical? = null,
+    val depthSpatialAlignment: SpatialAlignment.Depth? = null,
+) : SubspaceModifierElement<RowColumnAlignNode>() {
+    override fun create(): RowColumnAlignNode =
+        RowColumnAlignNode(
+            horizontalSpatialAlignment,
+            verticalSpatialAlignment,
+            depthSpatialAlignment
+        )
+
+    override fun update(node: RowColumnAlignNode) {
+        node.horizontalSpatialAlignment = horizontalSpatialAlignment
+        node.verticalSpatialAlignment = verticalSpatialAlignment
+        node.depthSpatialAlignment = depthSpatialAlignment
+    }
+
+    override fun hashCode(): Int {
+        var result = horizontalSpatialAlignment.hashCode()
+        result = 31 * result + verticalSpatialAlignment.hashCode()
+        result = 31 * result + depthSpatialAlignment.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherModifier = other as? RowColumnAlignElement ?: return false
+        return horizontalSpatialAlignment == otherModifier.horizontalSpatialAlignment &&
+            verticalSpatialAlignment == otherModifier.verticalSpatialAlignment &&
+            depthSpatialAlignment == otherModifier.depthSpatialAlignment
+    }
+}
+
+internal class RowColumnAlignNode(
+    var horizontalSpatialAlignment: SpatialAlignment.Horizontal? = null,
+    var verticalSpatialAlignment: SpatialAlignment.Vertical? = null,
+    var depthSpatialAlignment: SpatialAlignment.Depth? = null,
+) : SubspaceModifier.Node(), ParentLayoutParamsModifier {
+    override fun adjustParams(params: ParentLayoutParamsAdjustable) {
+        if (params !is RowColumnSpatialAlignmentParentData) return
+        if (horizontalSpatialAlignment != null) {
+            params.horizontalSpatialAlignment = horizontalSpatialAlignment
+        }
+        if (verticalSpatialAlignment != null) {
+            params.verticalSpatialAlignment = verticalSpatialAlignment
+        }
+        if (depthSpatialAlignment != null) {
+            params.depthSpatialAlignment = depthSpatialAlignment
+        }
+    }
+}
+
+internal data class RowColumnSpatialAlignmentParentData(
+    var horizontalSpatialAlignment: SpatialAlignment.Horizontal? = null,
+    var verticalSpatialAlignment: SpatialAlignment.Vertical? = null,
+    var depthSpatialAlignment: SpatialAlignment.Depth? = null,
+) : ParentLayoutParamsAdjustable
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnMeasurePolicy.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnMeasurePolicy.kt
new file mode 100644
index 0000000..9bbd6bb
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/RowColumnMeasurePolicy.kt
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.xr.compose.subspace.LayoutOrientation.Horizontal
+import androidx.xr.compose.subspace.LayoutOrientation.Vertical
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasurePolicy
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.Placeable
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.compose.unit.constrainDepth
+import androidx.xr.compose.unit.constrainHeight
+import androidx.xr.compose.unit.constrainWidth
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import kotlin.math.cos
+import kotlin.math.sign
+import kotlin.math.sin
+
+/** Shared [MeasurePolicy] between [Row] and [Column]. */
+internal class RowColumnMeasurePolicy(
+    private val orientation: LayoutOrientation,
+    private val alignment: SpatialAlignment,
+    private val curveRadius: Dp,
+) : MeasurePolicy {
+    override fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        // Space taken up on the main axis by children with no weight modifier
+        var fixedSpace = 0
+
+        // The total amount of weight declared across all children
+        var totalWeight = 0f
+
+        val resolvedMeasurables = measurables.map { ResolvedMeasurable(it) }
+
+        // We will measure non-weighted children in this pass.
+        resolvedMeasurables.forEach { resolvedMeasurable ->
+            if (resolvedMeasurable.weightInfo.weight > 0f) {
+                // Children with weight will be measured after all others
+                totalWeight += resolvedMeasurable.weightInfo.weight
+            } else {
+                // Children without weight will be measured now
+                resolvedMeasurable.placeable =
+                    resolvedMeasurable.measurable
+                        .measure(constraints.plusMainAxis(-fixedSpace))
+                        .also { fixedSpace += it.mainAxisSize() }
+            }
+        }
+
+        // Now we can measure the weighted children (if any).
+        if (totalWeight > 0f) {
+            // Amount of space this Row/Column wants to fill up
+            val targetSpace = constraints.mainAxisTargetSpace()
+            // Amount of space left (after the non-weighted children were measured)
+            val remainingToTarget = (targetSpace - fixedSpace).coerceAtLeast(0)
+            // Amount of space that would be given to a weighted child with `.weight(1f)`
+            val weightUnitSpace = remainingToTarget / totalWeight
+
+            // First pass through the weighted children, we just want to see what the remaining
+            // space is.
+            // Due to rounding, we may over/underfill the container.
+            //
+            // For example, if we have a 200dp row with 7 children, each with a weight of 1f:
+            // 200/7 ~= 28.57..., which gets rounded to 29. But 29*7 = 203, so we've overfilled our
+            // 200dp row by 3dp.
+            //
+            // The fix is to track this remainder N, and adjust the first N children by 1dp or -1dp
+            // so
+            // that the row/column will be the exact size we need.
+            var remainder = remainingToTarget
+            resolvedMeasurables.forEach { resolvedMeasurable ->
+                if (resolvedMeasurable.weightInfo.weight <= 0f) return@forEach
+                val weightedSize = resolvedMeasurable.weightInfo.weight * weightUnitSpace
+                remainder -= weightedSize.fastRoundToInt()
+            }
+
+            resolvedMeasurables.forEach { resolvedMeasurable ->
+                if (resolvedMeasurable.weightInfo.weight <= 0f) return@forEach
+                val childMainAxisSize = run {
+                    val remainderUnit = remainder.sign
+                    remainder -= remainderUnit
+                    val weightedSize = resolvedMeasurable.weightInfo.weight * weightUnitSpace
+                    weightedSize.fastRoundToInt() + remainderUnit
+                }
+                val childConstraints =
+                    buildConstraints(
+                        mainAxisMin =
+                            if (resolvedMeasurable.weightInfo.fill) childMainAxisSize else 0,
+                        mainAxisMax = childMainAxisSize,
+                        crossAxisMin = constraints.crossAxisMin(),
+                        crossAxisMax = constraints.crossAxisMax(),
+                        minDepth = constraints.minDepth,
+                        maxDepth = constraints.maxDepth,
+                    )
+                resolvedMeasurable.placeable =
+                    resolvedMeasurable.measurable.measure(childConstraints)
+            }
+        }
+
+        val contentSize = resolvedMeasurables.contentSize()
+
+        val containerSize =
+            IntVolumeSize(
+                width = constraints.constrainWidth(contentSize.width),
+                height = constraints.constrainHeight(contentSize.height),
+                depth = constraints.constrainDepth(contentSize.depth),
+            )
+
+        // Each child will have its main-axis offset adjusted, based on extra space available and
+        // the
+        // provided alignment.
+        val mainAxisOffset =
+            if (isHorizontal()) {
+                // `mainAxisOffset` represents the left edge of the content in the container space.
+                alignment.horizontalOffset(contentSize.width, containerSize.width) -
+                    contentSize.width / 2
+            } else {
+                // `mainAxisOffset` represents the top edge of the content in the container space.
+                alignment.verticalOffset(contentSize.height, containerSize.height) +
+                    contentSize.height / 2
+            }
+        resolvedMeasurables.forEach { resolvedMeasurable ->
+            // Adjust main-axis offset appropriately.
+            resolvedMeasurable.mainAxisPosition =
+                resolvedMeasurable.mainAxisPosition!! + mainAxisOffset
+
+            // Set child's cross-axis position based on its desired size + the container's
+            // size/alignment.
+            val crossAxisSize = resolvedMeasurable.placeable!!.crossAxisSize()
+            resolvedMeasurable.crossAxisPosition =
+                if (isHorizontal()) {
+                    resolvedMeasurable.verticalOffset(
+                        crossAxisSize,
+                        containerSize.height,
+                        alignment
+                    )
+                } else {
+                    resolvedMeasurable.horizontalOffset(
+                        crossAxisSize,
+                        containerSize.width,
+                        alignment
+                    )
+                }
+        }
+
+        return layout(containerSize.width, containerSize.height, containerSize.depth) {
+            resolvedMeasurables.forEach { resolvedMeasurable ->
+                val placeable = resolvedMeasurable.placeable!!
+                val mainAxisPosition = resolvedMeasurable.mainAxisPosition!!
+                val crossAxisPosition = resolvedMeasurable.crossAxisPosition!!
+                val depthPosition =
+                    resolvedMeasurable.depthOffset(
+                        placeable.measuredDepth,
+                        containerSize.depth,
+                        alignment
+                    )
+                var position =
+                    Vector3(
+                        x =
+                            if (isHorizontal()) mainAxisPosition.toFloat()
+                            else crossAxisPosition.toFloat(),
+                        y =
+                            if (isHorizontal()) crossAxisPosition.toFloat()
+                            else mainAxisPosition.toFloat(),
+                        z = depthPosition.toFloat(),
+                    )
+                var orientation = Quaternion.Identity
+
+                if (curveRadius != Dp.Infinity) {
+                    val pixelsCurveRadius = curveRadius.toPx()
+
+                    // NOTE: Orientation needs to be computed first, otherwise position
+                    // gets overwritten with the new position which will lead to an
+                    // incorrect orientation calculation.
+                    orientation = getOrientationTangentToCircle(position, pixelsCurveRadius)
+                    position = getPositionOnCircle(position, pixelsCurveRadius)
+                }
+
+                placeable.place(Pose(position, orientation))
+            }
+        }
+    }
+
+    private fun isHorizontal() = orientation == LayoutOrientation.Horizontal
+
+    private fun Placeable.mainAxisSize() = if (isHorizontal()) measuredWidth else measuredHeight
+
+    private fun Placeable.crossAxisSize() = if (isHorizontal()) measuredHeight else measuredWidth
+
+    private fun VolumeConstraints.plusMainAxis(addToMainAxis: Int): VolumeConstraints {
+        val newMainAxisValue =
+            if (isHorizontal()) {
+                maxWidth + addToMainAxis
+            } else {
+                maxHeight + addToMainAxis
+            }
+        return VolumeConstraints(
+            minWidth = 0,
+            maxWidth = if (isHorizontal()) newMainAxisValue else maxWidth,
+            minHeight = 0,
+            maxHeight = if (isHorizontal()) maxHeight else newMainAxisValue,
+            minDepth = 0,
+            maxDepth = maxDepth,
+        )
+    }
+
+    private fun buildConstraints(
+        mainAxisMin: Int,
+        mainAxisMax: Int,
+        crossAxisMin: Int,
+        crossAxisMax: Int,
+        minDepth: Int,
+        maxDepth: Int,
+    ): VolumeConstraints {
+        val isHorizontal = isHorizontal()
+        return VolumeConstraints(
+            minWidth = if (isHorizontal) mainAxisMin else crossAxisMin,
+            maxWidth = if (isHorizontal) mainAxisMax else crossAxisMax,
+            minHeight = if (isHorizontal) crossAxisMin else mainAxisMin,
+            maxHeight = if (isHorizontal) crossAxisMax else mainAxisMax,
+            minDepth = minDepth,
+            maxDepth = maxDepth,
+        )
+    }
+
+    private fun List<ResolvedMeasurable>.contentSize(): IntVolumeSize {
+        // Content's main-axis size is the sum of all children's main-axis sizes
+        var mainAxisSize = 0
+        val mainAxisMultiplier = if (isHorizontal()) 1 else -1
+        // Content's cross-axis and depth size are the max measured value of all children
+        var crossAxisSize = 0
+        var depthSize = 0
+        this.forEach { resolvedMeasurable ->
+            val placeable = resolvedMeasurable.placeable!!
+            resolvedMeasurable.mainAxisPosition =
+                ((mainAxisSize + placeable.mainAxisSize() / 2.0f) * mainAxisMultiplier)
+                    .fastRoundToInt()
+            mainAxisSize += placeable.mainAxisSize()
+            crossAxisSize = maxOf(crossAxisSize, placeable.crossAxisSize())
+            depthSize = maxOf(depthSize, placeable.measuredDepth)
+        }
+        return IntVolumeSize(
+            width = if (isHorizontal()) mainAxisSize else crossAxisSize,
+            height = if (isHorizontal()) crossAxisSize else mainAxisSize,
+            depth = depthSize,
+        )
+    }
+
+    private fun VolumeConstraints.mainAxisTargetSpace(): Int {
+        val mainAxisMax = if (isHorizontal()) maxWidth else maxHeight
+        return if (mainAxisMax != VolumeConstraints.INFINITY) {
+            mainAxisMax
+        } else {
+            if (isHorizontal()) minWidth else minHeight
+        }
+    }
+
+    private fun VolumeConstraints.crossAxisMin(): Int = if (isHorizontal()) minHeight else minWidth
+
+    private fun VolumeConstraints.crossAxisMax(): Int = if (isHorizontal()) maxHeight else maxWidth
+}
+
+// [radius], like [position], should be in pixels.
+private fun getPositionOnCircle(position: Vector3, radius: Float): Vector3 {
+    // NOTE: This method is hard coded to work with rows.  Needs to be made
+    // slightly more general to work with columns.
+    val arclength = position.x // Signed, negative means arc extends to left.
+    val theta = arclength / radius
+    val x = radius * sin(theta)
+    val y = position.y
+    val z = radius * (1.0f - cos(theta)) + position.z
+    return Vector3(x.toInt().toFloat(), y.toInt().toFloat(), z.toInt().toFloat())
+}
+
+// [radius], like [position], should be in pixels.
+private fun getOrientationTangentToCircle(position: Vector3, radius: Float): Quaternion {
+    // NOTE: This method is hard coded to work with rows.  Needs to be made
+    // slightly more general to work with columns.
+    val arclength = position.x // Signed, negative means arc extends to left.
+    val theta = arclength / radius
+
+    // We need to rotate by negative theta (clockwise) around the Y axis.
+    val qX = 0.0f
+    val qY = sin(-theta * 0.5f)
+    val qZ = 0.0f
+    val qW = cos(-theta * 0.5f)
+
+    return Quaternion(qX, qY, qZ, qW)
+}
+
+/** A [Measurable] and all associated information computed in [RowColumnMeasurePolicy.measure]. */
+private class ResolvedMeasurable(val measurable: Measurable) {
+    /** Parameters set by the [RowScope.weight] and [ColumnScope.weight] modifiers. */
+    val weightInfo: RowColumnParentData = RowColumnParentData().also { measurable.adjustParams(it) }
+
+    /** Parameters set by the [RowScope.align] and [ColumnScope.align] modifiers. */
+    val alignment: RowColumnSpatialAlignmentParentData =
+        RowColumnSpatialAlignmentParentData().also { measurable.adjustParams(it) }
+
+    /** A measured placeable, only present once [Measurable.measure] is called on [measurable]. */
+    var placeable: Placeable? = null
+
+    /** The main-axis position of this child in its parent; set after all children are measured. */
+    var mainAxisPosition: Int? = null
+
+    /** The cross-axis position of this child in its parent; set after all children are measured. */
+    var crossAxisPosition: Int? = null
+
+    fun horizontalOffset(width: Int, space: Int, parentSpatialAlignment: SpatialAlignment): Int =
+        alignment.horizontalSpatialAlignment?.offset(width, space)
+            ?: parentSpatialAlignment.horizontalOffset(width, space)
+
+    fun verticalOffset(height: Int, space: Int, parentSpatialAlignment: SpatialAlignment): Int =
+        alignment.verticalSpatialAlignment?.offset(height, space)
+            ?: parentSpatialAlignment.verticalOffset(height, space)
+
+    fun depthOffset(depth: Int, space: Int, parentSpatialAlignment: SpatialAlignment): Int =
+        alignment.depthSpatialAlignment?.offset(depth, space)
+            ?: parentSpatialAlignment.depthOffset(depth, space)
+
+    override fun toString(): String {
+        return measurable.toString()
+    }
+}
+
+/** [Row] is [Horizontal], [Column] is [Vertical]. */
+internal enum class LayoutOrientation {
+    Horizontal,
+    Vertical,
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialBox.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialBox.kt
new file mode 100644
index 0000000..2223978
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialBox.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.layout.LayoutScopeMarker
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasurePolicy
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsModifier
+import androidx.xr.compose.subspace.layout.Placeable
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import kotlin.math.max
+
+/**
+ * A layout composable that sizes itself to fit its content, subject to incoming constraints.
+ *
+ * A layout composable with [content]. The [SpatialBox] will size itself to fit the content, subject
+ * to the incoming constraints. When children are smaller than the parent, by default they will be
+ * positioned inside the [SpatialBox] according to the [alignment]. For individually specifying the
+ * alignments of the children layouts, use the [SpatialBoxScope.align] modifier. By default, the
+ * content will be measured without the [SpatialBox]'s incoming min constraints. If
+ * [propagateMinConstraints] is set to `true`, the min size set on the [SpatialBox] will also be
+ * applied to the content.
+ *
+ * Note: If the content has multiple children, they might overlap depending on their positioning.
+ *
+ * @param modifier The modifier to be applied to the layout.
+ * @param alignment The default alignment of children within the [SpatialBox].
+ * @param propagateMinConstraints Whether the incoming min constraints should be passed to content.
+ * @param name The name for the [SpatialBox].
+ * @param content The content of the [SpatialBox].
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialBox(
+    modifier: SubspaceModifier = SubspaceModifier,
+    alignment: SpatialAlignment = SpatialAlignment.Center,
+    propagateMinConstraints: Boolean = false,
+    name: String = defaultSpatialBoxName(),
+    content: @Composable @SubspaceComposable SpatialBoxScope.() -> Unit,
+) {
+    SubspaceLayout(
+        modifier = modifier,
+        content = { SpatialBoxScopeInstance.content() },
+        measurePolicy = SpatialBoxMeasurePolicy(alignment, propagateMinConstraints),
+        name = name,
+    )
+}
+
+private var spatialBoxNamePart: Int = 0
+
+private fun defaultSpatialBoxName(): String {
+    return "SpatialBox-${spatialBoxNamePart++}"
+}
+
+/** [MeasurePolicy] for [SpatialBox]. */
+internal class SpatialBoxMeasurePolicy(
+    private val alignment: SpatialAlignment,
+    private val propagateMinConstraints: Boolean,
+) : MeasurePolicy {
+    override fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        if (measurables.isEmpty()) {
+            return layout(constraints.minWidth, constraints.minHeight, constraints.minDepth) {}
+        }
+
+        val contentConstraints =
+            if (propagateMinConstraints) {
+                constraints
+            } else {
+                constraints.copy(minWidth = 0, minHeight = 0, minDepth = 0)
+            }
+
+        val placeables = arrayOfNulls<Placeable>(measurables.size)
+        var boxWidth = constraints.minWidth
+        var boxHeight = constraints.minHeight
+        var boxDepth = constraints.minDepth
+        measurables.fastForEachIndexed { index, measurable ->
+            val placeable = measurable.measure(contentConstraints)
+            placeables[index] = placeable
+            boxWidth = max(boxWidth, placeable.measuredWidth)
+            boxHeight = max(boxHeight, placeable.measuredHeight)
+            boxDepth = max(boxDepth, placeable.measuredDepth)
+        }
+
+        return layout(boxWidth, boxHeight, boxDepth) {
+            val space = IntVolumeSize(boxWidth, boxHeight, boxDepth)
+            placeables.forEachIndexed { index, placeable ->
+                placeable as Placeable
+                val measurable = measurables[index]
+                val childSpatialAlignment =
+                    SpatialBoxParentData(alignment).also { measurable.adjustParams(it) }.alignment
+                placeable.place(Pose(childSpatialAlignment.position(placeable.size(), space)))
+            }
+        }
+    }
+
+    private fun Placeable.size() = IntVolumeSize(measuredWidth, measuredHeight, measuredDepth)
+}
+
+/** Scope for the children of [SpatialBox]. */
+@LayoutScopeMarker
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialBoxScope {
+    /**
+     * Positions the content element at a specific [SpatialAlignment] within the [SpatialBox]. This
+     * alignment overrides the default [alignment] of the [SpatialBox].
+     *
+     * @param alignment The desired alignment for the content.
+     * @return The modified SubspaceModifier.
+     */
+    public fun SubspaceModifier.align(alignment: SpatialAlignment): SubspaceModifier
+}
+
+internal object SpatialBoxScopeInstance : SpatialBoxScope {
+    override fun SubspaceModifier.align(alignment: SpatialAlignment): SubspaceModifier {
+        return this then LayoutAlignElement(alignment = alignment)
+    }
+}
+
+private class LayoutAlignElement(val alignment: SpatialAlignment) :
+    SubspaceModifierElement<LayoutAlignNode>() {
+    override fun create(): LayoutAlignNode = LayoutAlignNode(alignment)
+
+    override fun update(node: LayoutAlignNode) {
+        node.alignment = alignment
+    }
+
+    override fun hashCode(): Int {
+        return alignment.hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherModifier = other as? LayoutAlignElement ?: return false
+        return alignment == otherModifier.alignment
+    }
+}
+
+private class LayoutAlignNode(var alignment: SpatialAlignment) :
+    SubspaceModifier.Node(), ParentLayoutParamsModifier {
+    override fun adjustParams(params: ParentLayoutParamsAdjustable) {
+        if (params is SpatialBoxParentData) {
+            params.alignment = alignment
+        }
+    }
+}
+
+private data class SpatialBoxParentData(var alignment: SpatialAlignment) :
+    ParentLayoutParamsAdjustable
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt
new file mode 100644
index 0000000..b310f43
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.FloatRange
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.layout.LayoutScopeMarker
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.runtime.math.Pose
+
+/**
+ * A layout composable that arranges its children in a vertical sequence.
+ *
+ * For arranging children horizontally, see [SpatialRow].
+ *
+ * @param modifier Modifiers to apply to the layout.
+ * @param alignment The default alignment for child elements within the column.
+ * @param name The name of the layout.
+ * @param content The composable content to be laid out vertically.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialColumn(
+    modifier: SubspaceModifier = SubspaceModifier,
+    alignment: SpatialAlignment = SpatialAlignment.Center,
+    name: String = defaultSpatialColumnName(),
+    content: @Composable @SubspaceComposable SpatialColumnScope.() -> Unit,
+) {
+    SubspaceLayout(
+        modifier = modifier,
+        content = { SpatialColumnScopeInstance.content() },
+        coreEntity =
+            rememberCoreContentlessEntity { createEntity(name = name, pose = Pose.Identity) },
+        name = name,
+        measurePolicy =
+            RowColumnMeasurePolicy(
+                orientation = LayoutOrientation.Vertical,
+                alignment = alignment,
+                curveRadius = Dp.Infinity,
+            ),
+    )
+}
+
+/** Scope for customizing the layout of children within a [SpatialColumn]. */
+@LayoutScopeMarker
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialColumnScope {
+    /**
+     * Sizes the element's height proportionally to its [weight] relative to other weighted sibling
+     * elements in the [SpatialColumn].
+     *
+     * The parent divides the remaining vertical space after measuring unweighted children and
+     * distributes it according to the weights.
+     *
+     * If [fill] is true, the element will occupy its entire allocated height. Otherwise, it can be
+     * smaller, potentially making the [SpatialColumn] smaller as unused space isn't redistributed.
+     *
+     * @param weight The proportional height for this element relative to other weighted siblings.
+     *   Must be positive.
+     * @param fill Whether the element should fill its entire allocated height.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.weight(
+        @FloatRange(from = 0.0, fromInclusive = false) weight: Float,
+        fill: Boolean = true,
+    ): SubspaceModifier
+
+    /**
+     * Aligns the element within the [SpatialColumn] horizontally. This will override the horizontal
+     * alignment value passed to the [SpatialColumn].
+     *
+     * @param alignment The horizontal alignment to apply.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.align(alignment: SpatialAlignment.Horizontal): SubspaceModifier
+
+    /**
+     * Aligns the element within the [SpatialColumn] depthwise. This will override the depth
+     * alignment value passed to the [SpatialColumn].
+     *
+     * @param alignment The depth alignment to use for the element.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.align(alignment: SpatialAlignment.Depth): SubspaceModifier
+}
+
+internal object SpatialColumnScopeInstance : SpatialColumnScope {
+    override fun SubspaceModifier.weight(weight: Float, fill: Boolean): SubspaceModifier {
+        require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }
+        return this then
+            LayoutWeightElement(
+                // Coerce Float.POSITIVE_INFINITY to Float.MAX_VALUE to avoid errors
+                weight = weight.coerceAtMost(Float.MAX_VALUE),
+                fill = fill,
+            )
+    }
+
+    override fun SubspaceModifier.align(alignment: SpatialAlignment.Horizontal): SubspaceModifier {
+        return this then RowColumnAlignElement(horizontalSpatialAlignment = alignment)
+    }
+
+    override fun SubspaceModifier.align(alignment: SpatialAlignment.Depth): SubspaceModifier {
+        return this then RowColumnAlignElement(depthSpatialAlignment = alignment)
+    }
+}
+
+private var spatialColumnNamePart: Int = 0
+
+private fun defaultSpatialColumnName(): String {
+    return "SpatialColumn-${spatialColumnNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialLayoutSpacer.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialLayoutSpacer.kt
new file mode 100644
index 0000000..73f901c
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialLayoutSpacer.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasurePolicy
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.height
+import androidx.xr.compose.subspace.layout.width
+import androidx.xr.compose.unit.VolumeConstraints
+
+/**
+ * A composable that represents an empty space layout. Its size can be controlled using modifiers
+ * like [SubspaceModifier.width], [SubspaceModifier.height], etc.
+ *
+ * @param modifier Modifiers to apply to the spacer.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialLayoutSpacer(modifier: SubspaceModifier = SubspaceModifier) {
+    SubspaceLayout(
+        name = defaultSpatialLayoutSpacerName(),
+        modifier = modifier,
+        measurePolicy = SpacerMeasurePolicy,
+    )
+}
+
+/**
+ * A composable that represents an empty space layout. Its size can be controlled using modifiers
+ * like [SubspaceModifier.width], [SubspaceModifier.height], etc.
+ *
+ * @param modifier Modifiers to apply to this spacer.
+ * @param name The name of this SpatialLayoutSpacer element.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialLayoutSpacer(
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultSpatialLayoutSpacerName(),
+) {
+    SubspaceLayout(name = name, modifier = modifier, measurePolicy = SpacerMeasurePolicy)
+}
+
+private object SpacerMeasurePolicy : MeasurePolicy {
+    override fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        return with(constraints) {
+            val width = if (hasBoundedWidth) maxWidth else 0
+            val height = if (hasBoundedHeight) maxHeight else 0
+            val depth = if (hasBoundedDepth) maxDepth else 0
+            layout(width, height, depth) {}
+        }
+    }
+}
+
+private var spatialLayoutSpacerNamePart: Int = 0
+
+private fun defaultSpatialLayoutSpacerName(): String {
+    return "Spacer-${spatialLayoutSpacerNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialPanel.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialPanel.kt
new file mode 100644
index 0000000..298715c
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialPanel.kt
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import android.content.Intent
+import android.graphics.Color
+import android.graphics.Rect
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.UiComposable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.platform.LocalDialogManager
+import androidx.xr.compose.platform.LocalPanelEntity
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.subspace.layout.CorePanelEntity
+import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
+import androidx.xr.compose.subspace.layout.SpatialShape
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Pose
+import androidx.xr.scenecore.Dimensions
+import androidx.xr.scenecore.PanelEntity
+
+private const val DEFAULT_SIZE_PX = 400
+
+/** Contains default values used by spatial panels. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object SpatialPanelDefaults {
+
+    /** Default shape for a Spatial Panel. */
+    public val shape: SpatialShape = SpatialRoundedCornerShape(CornerSize(32.dp))
+}
+
+/**
+ * Creates a [SpatialPanel] representing a 2D plane in 3D space in which an application can fill
+ * content.
+ *
+ * @param view Content view to be displayed within the SpatialPanel.
+ * @param modifier SubspaceModifiers to apply to the SpatialPanel.
+ * @param name A name for the SpatialPanel, useful for debugging.
+ * @param shape The shape of this Spatial Panel.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialPanel(
+    view: View,
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultSpatialPanelName(),
+    shape: SpatialShape = SpatialPanelDefaults.shape,
+) {
+    SpatialPanel(modifier, name, view, shape) {}
+}
+
+/**
+ * Creates a [SpatialPanel] representing a 2D plane in 3D space in which an application can fill
+ * content.
+ *
+ * @param modifier SubspaceModifiers to apply to the SpatialPanel.
+ * @param name A name for the SpatialPanel, useful for debugging.
+ * @param shape The shape of this Spatial Panel.
+ * @param content The composable content to render within the SpatialPanel.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialPanel(
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultSpatialPanelName(),
+    shape: SpatialShape = SpatialPanelDefaults.shape,
+    content: @Composable @UiComposable () -> Unit,
+) {
+    var panelEntity by remember { mutableStateOf<PanelEntity?>(null) }
+
+    SpatialPanel(
+        modifier = modifier,
+        name = name,
+        view =
+            rememberComposeView {
+                CompositionLocalProvider(LocalPanelEntity provides panelEntity, content = content)
+            },
+        shape = shape,
+        onPanelEntityCreated = { panelEntity = it },
+    )
+}
+
+/**
+ * Creates a [SpatialPanel] backed by the main Window content.
+ *
+ * This panel requires the following specific configuration in the Android Manifest for proper
+ * sizing/resizing behavior:
+ * ```
+ * <activity
+ * android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize>
+ * <!--suppress AndroidElementNotAllowed -->
+ * <layout android:defaultWidth="50dp" android:defaultHeight="50dp" android:minHeight="50dp"
+ * android:minWidth="50dp"/>
+ * </activity>
+ * ```
+ *
+ * @param modifier SubspaceModifier to apply to the MainPanel.
+ * @param shape The shape of this Spatial Panel.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun MainPanel(
+    modifier: SubspaceModifier = SubspaceModifier,
+    shape: SpatialShape = SpatialPanelDefaults.shape,
+) {
+    val session = checkNotNull(LocalSession.current) { "session must be initialized" }
+
+    DisposableEffect(Unit) {
+        session.mainPanelEntity.setHidden(false)
+        onDispose { session.mainPanelEntity.setHidden(true) }
+    }
+
+    LayoutPanelEntity(
+        // Do not use rememberCorePanelEntity since we do not want to dispose the main panel entity.
+        remember { CorePanelEntity(session, session.mainPanelEntity) },
+        "MainPanel",
+        shape,
+        modifier,
+    )
+}
+
+/**
+ * Creates a [SpatialPanel] and launches an Activity within it.
+ *
+ * @param intent The intent of an Activity to launch within this panel.
+ * @param modifier SubspaceModifiers to apply to the SpatialPanel.
+ * @param name A name for the SpatialPanel, useful for debugging.
+ * @param shape The shape of this Spatial Panel.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialPanel(
+    intent: Intent,
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = "ActivityPanel-${intent.action}",
+    shape: SpatialShape = SpatialPanelDefaults.shape,
+) {
+    LayoutPanelEntity(
+        rememberCorePanelEntity {
+            val rect = Rect(0, 0, DEFAULT_SIZE_PX, DEFAULT_SIZE_PX)
+            createActivityPanelEntity(rect, name).also { it.launchActivity(intent) }
+        },
+        name,
+        shape,
+        modifier,
+    )
+}
+
+/**
+ * Private [SpatialPanel] implementation that reports its created PanelEntity.
+ *
+ * @param modifier SubspaceModifiers.
+ * @param view content view to render inside the SpatialPanel
+ * @param shape The shape of this Spatial Panel.
+ * @param onPanelEntityCreated callback to consume the [PanelEntity] when it is created
+ */
+@Composable
+@SubspaceComposable
+private fun SpatialPanel(
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String,
+    view: View,
+    shape: SpatialShape,
+    onPanelEntityCreated: (PanelEntity) -> Unit,
+) {
+    val minimumPanelDimension = Dimensions(10f, 10f, 10f)
+
+    val frameLayout = remember {
+        FrameLayout(view.context).also {
+            if (view.parent != it) {
+                val parent = view.parent as? ViewGroup
+                parent?.removeView(view)
+                it.addView(view)
+            }
+        }
+    }
+
+    val scrim = remember { View(view.context) }
+    val dialogManager = LocalDialogManager.current
+    LaunchedEffect(dialogManager.isSpatialDialogActive.value) {
+        if (dialogManager.isSpatialDialogActive.value) {
+            scrim.setBackgroundColor(Color.argb(90, 0, 0, 0))
+            val scrimLayoutParams =
+                FrameLayout.LayoutParams(
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                    FrameLayout.LayoutParams.MATCH_PARENT,
+                )
+
+            if (scrim.parent == null) {
+                frameLayout.addView(scrim, scrimLayoutParams)
+            }
+
+            scrim.setOnClickListener { dialogManager.isSpatialDialogActive.value = false }
+        } else {
+            frameLayout.removeView(scrim)
+        }
+    }
+
+    LayoutPanelEntity(
+        rememberCorePanelEntity {
+            createPanelEntity(
+                    view = frameLayout,
+                    surfaceDimensionsPx = minimumPanelDimension,
+                    dimensions = minimumPanelDimension,
+                    name = name,
+                    pose = Pose.Identity,
+                )
+                .also(onPanelEntityCreated)
+        },
+        name,
+        shape,
+        modifier,
+    )
+}
+
+/**
+ * Lay out the SpatialPanel using the provided [CorePanelEntity].
+ *
+ * @param coreEntity The [CorePanelEntity] associated with this SpatialPanel. It should be based on
+ *   a SceneCore panel entity.
+ * @param name The name of the panel for debugging purposes.
+ * @param shape The shape of this Spatial Panel.
+ * @param modifier The [SubspaceModifier] attached to this compose node.
+ */
+@Composable
+private fun LayoutPanelEntity(
+    coreEntity: CorePanelEntity,
+    name: String,
+    shape: SpatialShape,
+    modifier: SubspaceModifier,
+) {
+    val density = LocalDensity.current
+    SubspaceLayout(modifier = modifier, coreEntity = coreEntity, name = name) {
+        measurables,
+        constraints ->
+        val initialWidth = DEFAULT_SIZE_PX.coerceIn(constraints.minWidth, constraints.maxWidth)
+        val initialHeight = DEFAULT_SIZE_PX.coerceIn(constraints.minHeight, constraints.maxHeight)
+        val initialDepth = DEFAULT_SIZE_PX.coerceIn(constraints.minDepth, constraints.maxDepth)
+
+        val placeables = measurables.map { it.measure(constraints) }
+
+        val maxSize =
+            placeables.fold(IntVolumeSize(initialWidth, initialHeight, initialDepth)) {
+                currentMax,
+                placeable ->
+                IntVolumeSize(
+                    width = maxOf(currentMax.width, placeable.measuredWidth),
+                    height = maxOf(currentMax.height, placeable.measuredHeight),
+                    depth = maxOf(currentMax.depth, placeable.measuredDepth),
+                )
+            }
+
+        val maxWidth = maxSize.width
+        val maxHeight = maxSize.height
+
+        if (shape is SpatialRoundedCornerShape) {
+            coreEntity.setCornerRadius(
+                shape.computeCornerRadius(maxWidth.toFloat(), maxHeight.toFloat(), density),
+                density,
+            )
+        }
+
+        // Reserve space in the original composition
+        layout(maxWidth, maxHeight, maxSize.depth) { placeables.forEach { it.place(Pose()) } }
+    }
+}
+
+private var spatialPanelNamePart: Int = 0
+
+private fun defaultSpatialPanelName(): String {
+    return "Panel-${spatialPanelNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialRow.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialRow.kt
new file mode 100644
index 0000000..66af29e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialRow.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.FloatRange
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.layout.LayoutScopeMarker
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.runtime.math.Pose
+
+/**
+ * A layout composable that arranges its children in a horizontal sequence. For arranging children
+ * vertically, see [SpatialColumn].
+ *
+ * @param modifier Appearance modifiers to apply to this Composable.
+ * @param alignment The default alignment for child elements within the row.
+ * @param curveRadius The radial distance (in Dp) of the polar coordinate system of this row. It is
+ *   a positive value. Setting this value to Dp.Infinity or a non-positive value will flatten the
+ *   row. When a row is curved, its elements will be oriented so that they lie tangent to the curved
+ *   row. A typical curved row has a curve radius of 825.dp.
+ * @param name A string name to associated with the SpatialRow. This can be useful identifying the
+ *   SpatialRow when debugging spatial applications.
+ * @param content The composable content to be laid out horizontally in the row.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SpatialRow(
+    modifier: SubspaceModifier = SubspaceModifier,
+    alignment: SpatialAlignment = SpatialAlignment.Center,
+    curveRadius: Dp = Dp.Infinity,
+    name: String = defaultSpatialRowName(),
+    content: @Composable @SubspaceComposable SpatialRowScope.() -> Unit,
+) {
+    SubspaceLayout(
+        modifier = modifier,
+        content = { SpatialRowScopeInstance.content() },
+        coreEntity =
+            rememberCoreContentlessEntity { createEntity(name = name, pose = Pose.Identity) },
+        name = name,
+        measurePolicy =
+            RowColumnMeasurePolicy(
+                orientation = LayoutOrientation.Horizontal,
+                alignment = alignment,
+                curveRadius = if (curveRadius > 0.dp) curveRadius else Dp.Infinity,
+            ),
+    )
+}
+
+/** Scope for customizing the layout of children within a [SpatialRow]. */
+@LayoutScopeMarker
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialRowScope {
+    /**
+     * Sizes the element's width proportionally to its [weight] relative to other weighted sibling
+     * elements in the [SpatialRow].
+     *
+     * The parent divides the remaining horizontal space after measuring unweighted children and
+     * distributes it according to the weights.
+     *
+     * If [fill] is true, the element will occupy its entire allocated width. Otherwise, it can be
+     * smaller, potentially making the [SpatialRow] smaller as unused space isn't redistributed.
+     *
+     * @param weight The proportional width for this element relative to other weighted siblings.
+     *   Must be positive.
+     * @param fill Whether the element should fill its entire allocated width.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.weight(
+        @FloatRange(from = 0.0, fromInclusive = false) weight: Float,
+        fill: Boolean = true,
+    ): SubspaceModifier
+
+    /**
+     * Aligns the element vertically within the [SpatialRow], overriding the row's default vertical
+     * alignment.
+     *
+     * @param alignment The vertical alignment to apply.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.align(alignment: SpatialAlignment.Vertical): SubspaceModifier
+
+    /**
+     * Aligns the element depthwise within the [SpatialRow], overriding the row's default depth
+     * alignment.
+     *
+     * @param alignment The depth alignment to apply.
+     * @return The modified [SubspaceModifier].
+     */
+    public fun SubspaceModifier.align(alignment: SpatialAlignment.Depth): SubspaceModifier
+}
+
+internal object SpatialRowScopeInstance : SpatialRowScope {
+    override fun SubspaceModifier.weight(weight: Float, fill: Boolean): SubspaceModifier {
+        require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }
+        return this then
+            LayoutWeightElement(
+                // Coerce Float.POSITIVE_INFINITY to Float.MAX_VALUE to avoid errors
+                weight = weight.coerceAtMost(Float.MAX_VALUE),
+                fill = fill,
+            )
+    }
+
+    override fun SubspaceModifier.align(alignment: SpatialAlignment.Vertical): SubspaceModifier {
+        return this then RowColumnAlignElement(verticalSpatialAlignment = alignment)
+    }
+
+    override fun SubspaceModifier.align(alignment: SpatialAlignment.Depth): SubspaceModifier {
+        return this then RowColumnAlignElement(depthSpatialAlignment = alignment)
+    }
+}
+
+private var spatialRowNamePart: Int = 0
+
+private fun defaultSpatialRowName(): String {
+    return "SpatialRow-${spatialRowNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SubspaceComposable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SubspaceComposable.kt
new file mode 100644
index 0000000..231e96d
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SubspaceComposable.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.ComposableTargetMarker
+
+/**
+ * Marks a composable function or other code element as intended for use within the context of
+ * [SubspaceComposable] functions.
+ */
+@Retention(AnnotationRetention.BINARY)
+@ComposableTargetMarker(description = "Subspace Composable")
+@Target(
+    AnnotationTarget.FILE,
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.TYPE,
+    AnnotationTarget.TYPE_PARAMETER,
+)
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public annotation class SubspaceComposable
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/Volume.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/Volume.kt
new file mode 100644
index 0000000..92e5aae
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/Volume.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Pose
+import androidx.xr.scenecore.Entity
+
+/**
+ * A composable that represents a 3D volume of space within which an application can fill content.
+ *
+ * This composable provides a [Entity] through the [onVolumeEntity] lambda, allowing the caller to
+ * attach child Jetpack XR Entities to it.
+ *
+ * @param modifier SubspaceModifiers to apply to the Volume.
+ * @param name A name associated with this Volume entity, useful for debugging.
+ * @param onVolumeEntity A lambda function that will be invoked when the [Entity] becomes available.
+ */
+@Composable
+@SubspaceComposable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Volume(
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultVolumeName(),
+    onVolumeEntity: (Entity) -> Unit,
+) {
+    val defaultWidthPx = 400
+    val defaultHeightPx = 400
+    val defaultDepthPx = 400
+
+    SubspaceLayout(
+        modifier = modifier,
+        coreEntity =
+            rememberCoreContentlessEntity {
+                createEntity(name = name, pose = Pose.Identity).apply(onVolumeEntity)
+            },
+        name = name,
+    ) { measurables, constraints ->
+        val initialWidth = defaultWidthPx.coerceIn(constraints.minWidth, constraints.maxWidth)
+        val initialHeight = defaultHeightPx.coerceIn(constraints.minHeight, constraints.maxHeight)
+        val initialDepth = defaultDepthPx.coerceIn(constraints.minDepth, constraints.maxDepth)
+
+        val placeables = measurables.map { it.measure(constraints) }
+
+        val maxSize =
+            placeables.fold(IntVolumeSize(initialWidth, initialHeight, initialDepth)) {
+                currentMax,
+                placeable ->
+                IntVolumeSize(
+                    width = maxOf(currentMax.width, placeable.measuredWidth),
+                    height = maxOf(currentMax.height, placeable.measuredHeight),
+                    depth = maxOf(currentMax.depth, placeable.measuredDepth),
+                )
+            }
+
+        // Reserve space in the original composition
+        layout(maxSize.width, maxSize.height, maxSize.depth) {
+            placeables.forEach { it.place(Pose()) }
+        }
+    }
+}
+
+private var volumeNamePart: Int = 0
+
+private fun defaultVolumeName(): String {
+    return "Volume-${volumeNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Alpha.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Alpha.kt
new file mode 100644
index 0000000..a070d0e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Alpha.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.FloatRange
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+
+/**
+ * Sets the opacity of this element (and its children) to a value between [0..1]. An alpha value of
+ * 0.0f means fully transparent while a value of 1.0f is completely opaque. Elements with
+ * semi-transparent alpha values (> 0.0 but < 1.0f) will be rendered using alpha-blending.
+ *
+ * @param alpha - Opacity of this element (and its children). Must be between `0` and `1`,
+ *   inclusive. Values < `0` or > `1` will be clamped.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.alpha(
+    @FloatRange(from = 0.0, to = 1.0) alpha: Float
+): SubspaceModifier = this.then(AlphaElement(alpha))
+
+private class AlphaElement(private var alpha: Float) : SubspaceModifierElement<AlphaNode>() {
+    init {
+        alpha = alpha.coerceIn(0.0f, 1.0f)
+    }
+
+    override fun create(): AlphaNode = AlphaNode(alpha)
+
+    override fun update(node: AlphaNode) {
+        node.alpha = alpha
+    }
+
+    override fun hashCode(): Int {
+        return alpha.hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is AlphaElement) return false
+
+        return alpha == other.alpha
+    }
+}
+
+private class AlphaNode(public var alpha: Float) : SubspaceModifier.Node(), CoreEntityNode {
+    override fun modifyCoreEntity(coreEntity: CoreEntity) {
+        coreEntity.setAlpha(alpha)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
new file mode 100644
index 0000000..34e33d00
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
@@ -0,0 +1,569 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import android.content.res.Resources
+import android.util.Log
+import androidx.compose.ui.unit.Density
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceLayoutNode
+import androidx.xr.compose.subspace.node.coordinator
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.Meter
+import androidx.xr.compose.unit.toDimensionsInMeters
+import androidx.xr.compose.unit.toIntVolumeSize
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+import androidx.xr.scenecore.BasePanelEntity
+import androidx.xr.scenecore.ContentlessEntity
+import androidx.xr.scenecore.Dimensions
+import androidx.xr.scenecore.Entity
+import androidx.xr.scenecore.MovableComponent
+import androidx.xr.scenecore.MoveListener
+import androidx.xr.scenecore.PixelDimensions
+import androidx.xr.scenecore.ResizableComponent
+import androidx.xr.scenecore.ResizeListener
+import androidx.xr.scenecore.Session
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Wrapper class for Entities from SceneCore to provide convenience methods for working with
+ * Entities from SceneCore.
+ */
+internal sealed class CoreEntity(public val entity: Entity) : SubspaceLayoutCoordinates {
+
+    protected var movable: Movable? = null
+    protected var resizable: Resizable? = null
+
+    internal var layout: SubspaceLayoutNode.MeasurableLayout? = null
+        set(value) {
+            field = value
+            applyLayoutChanges()
+        }
+
+    internal fun applyLayoutChanges() {
+        // Compose XR uses pixels, SceneCore uses meters.
+        val corePose =
+            (layout?.poseInParentEntity ?: Pose.Identity).convertPixelsToMeters(DEFAULT_DENSITY)
+        if (entity.getPose() != corePose) {
+            entity.setPose(corePose)
+        }
+    }
+
+    override val pose: Pose
+        get() = movable?.userPose ?: Pose.Identity
+
+    override val poseInRoot: Pose
+        get() = pose.translate(sourcePoseInRoot.translation).rotate(sourcePoseInRoot.rotation)
+
+    private val sourcePoseInRoot: Pose
+        get() = coordinatesInRoot?.poseInRoot ?: Pose.Identity
+
+    private val coordinatesInRoot: SubspaceLayoutCoordinates?
+        get() =
+            layout
+                ?.tail
+                ?.traverseSelfThenAncestors()
+                ?.findInstance<SubspaceLayoutModifierNode>()
+                ?.coordinator ?: layout?.parentCoordinatesInRoot
+
+    override val poseInParentEntity: Pose
+        get() =
+            pose
+                .translate(sourcePoseInParentEntity.translation)
+                .rotate(sourcePoseInParentEntity.rotation)
+
+    private val sourcePoseInParentEntity: Pose
+        get() = coordinatesInParentEntity?.poseInParentEntity ?: Pose.Identity
+
+    private val coordinatesInParentEntity: SubspaceLayoutCoordinates?
+        get() =
+            layout
+                ?.tail
+                ?.traverseSelfThenAncestors()
+                ?.findInstance<SubspaceLayoutModifierNode>()
+                ?.coordinator ?: layout?.parentCoordinatesInParentEntity
+
+    override var size: IntVolumeSize = IntVolumeSize.Zero
+        set(value) {
+            val proposedSize = resizable?.userSize ?: value
+            if (field == proposedSize) {
+                return
+            }
+
+            setEntitySize(proposedSize)
+            field = proposedSize
+
+            movable?.setComponentSize(proposedSize)
+            resizable?.setComponentSize(proposedSize)
+        }
+
+    protected open fun setEntitySize(size: IntVolumeSize) {
+        entity.setSize(
+            Dimensions(size.width.toFloat(), size.height.toFloat(), size.depth.toFloat())
+        )
+    }
+
+    public var parent: CoreEntity? = null
+        set(value) {
+            field = value
+
+            // Leave SceneCore's parent as-is if we're trying to clear it out. SceneCore
+            // parents all
+            // newly-created non-Anchor entities under a world space point of reference for the
+            // activity
+            // space, but we don't have access to it. To maintain this parent-is-not-null property,
+            // we use
+            // this hack to keep the original parent, even if it's not technically correct when
+            // we're
+            // trying to reparent a node. The correct parent will be set on the "set" part of the
+            // reparent.
+            //
+            // TODO(b/356952297): Remove this hack once we can save and restore the original parent.
+            if (value == null) return
+
+            entity.setParent(value.entity)
+        }
+
+    internal fun applyModifiers(nodes: Sequence<SubspaceModifier.Node>) {
+        movable?.applyModifiers(nodes)
+        resizable?.applyModifiers(nodes)
+
+        // Apply any CoreEntityNode modifiers to [entity].
+        for (node in nodes.filterIsInstance<CoreEntityNode>()) {
+            node.modifyCoreEntity(this)
+        }
+    }
+
+    /**
+     * The scale of this entity relative to its parent. This value will affect the rendering of this
+     * Entity's children. As the scale increases, this will uniformly stretch the content of the
+     * Entity. This does not affect layout and other content will be laid out according to the
+     * original scale of the entity.
+     */
+    public var scale: Float
+        get() = entity.getScale()
+        set(value) = entity.setScale(value)
+
+    /**
+     * Sets the opacity of this entity (and its children) to a value between [0..1]. An alpha value
+     * of 0.0f means fully transparent while the value of 1.0f means fully opaque.
+     *
+     * @param alpha The opacity of this entity.
+     */
+    public fun setAlpha(alpha: Float) {
+        entity.setAlpha(alpha)
+    }
+
+    public companion object {
+        protected val LocalExecutor: Executor = Executors.newSingleThreadExecutor()
+
+        // TODO(djeu): Figure out if there's a better way here where there is no context.
+        private val DEFAULT_DENSITY: Density =
+            Density(
+                density = Resources.getSystem().displayMetrics.density,
+                fontScale = Resources.getSystem().configuration.fontScale,
+            )
+    }
+
+    protected inner class Movable(private val session: Session) {
+        /**
+         * The node should be movable.
+         *
+         * Right now this only affects the initial attachment of the component to the Core entity;
+         * however, this may change to be more reactive.
+         *
+         * TODO(b/358678496): Revisit this now that Movable cannot by disabled, except via the
+         *   'enabled' bit in the Node.
+         */
+        public var isEnabled: Boolean = true
+
+        /** Pose based on user adjustments from MoveEvents from SceneCore. */
+        public var userPose: Pose? = null
+            set(value) {
+                field = value
+                applyLayoutChanges()
+            }
+
+        private var initialOffset: Pose = Pose.Identity
+
+        /**
+         * Update the current state based on the modifiers applied to this [CoreEntity]. If a
+         * [MovableNode] is present then attach the component and apply its properties. Otherwise,
+         * detach the component. The last matching modifier is used, and earlier modifiers are
+         * ignored.
+         */
+        public fun applyModifiers(nodes: Sequence<SubspaceModifier.Node>) {
+            updateState(nodes.filterIsInstance<MovableNode>().lastOrNull())
+        }
+
+        /** Sets the size of the SysUI movable affordance. */
+        public fun setComponentSize(size: IntVolumeSize) {
+            if (isAttached) {
+                component.size = size.toDimensionsInMeters(DEFAULT_DENSITY)
+            }
+        }
+
+        /** All Compose XR params for the Movable modifier for this CoreEntity. */
+        private var movableNode: MovableNode? = null
+
+        /** Whether the movableComponent is attached to the entity. */
+        private var isAttached: Boolean = false
+
+        private val component: MovableComponent by lazy {
+            // Here we create the component and pass in false to systemMovable since Compose is
+            // going to
+            // handle the move events.
+            session.createMovableComponent(systemMovable = false)
+        }
+
+        /**
+         * Updates the movable state of this CoreEntity. Only update movable state if [Movable] is
+         * enabled.
+         *
+         * @param node The Movable modifier for this CoreEntity.
+         */
+        private fun updateState(node: MovableNode?) {
+            if (!isEnabled) {
+                if (node != null && node.enabled) logEnabledCheck()
+                return
+            }
+
+            movableNode = node
+            if (node != null && node.enabled) {
+                enableComponent()
+            } else {
+                disableComponent()
+            }
+        }
+
+        /** Enables the MovableComponent for this CoreEntity. */
+        private fun enableComponent() {
+            if (!isAttached) {
+                check(entity.addComponent(component)) {
+                    "Could not add MovableComponent to Core Entity"
+                }
+                component.addMoveListener(
+                    LocalExecutor,
+                    object : MoveListener {
+                        override fun onMoveStart(
+                            entity: Entity,
+                            initialInputRay: Ray,
+                            initialPose: Pose,
+                            initialScale: Float,
+                            initialParent: Entity,
+                        ) {
+                            // updatePoseOnMove() not called because initialPose should be the same
+                            // as the current
+                            // pose.
+                            initialOffset = sourcePoseInParentEntity
+                        }
+
+                        override fun onMoveUpdate(
+                            entity: Entity,
+                            currentInputRay: Ray,
+                            currentPose: Pose,
+                            currentScale: Float,
+                        ) {
+                            updatePoseOnMove(currentPose)
+                        }
+
+                        override fun onMoveEnd(
+                            entity: Entity,
+                            finalInputRay: Ray,
+                            finalPose: Pose,
+                            finalScale: Float,
+                            updatedParent: Entity?,
+                        ) {
+                            updatePoseOnMove(finalPose)
+                            initialOffset = Pose.Identity
+                        }
+                    },
+                )
+                // Ensure size is correct, since we do not update the size
+                // when the component is detached.
+                setComponentSize(size)
+                isAttached = true
+            }
+
+            // If the MovableComponent gets more internal state, copy it over
+            // from the modifier node here.
+        }
+
+        /**
+         * Disables the MovableComponent for this CoreEntity. Takes care of life cycle tasks for the
+         * underlying component in SceneCore.
+         */
+        private fun disableComponent() {
+            if (isAttached) {
+                entity.removeComponent(component)
+                isAttached = false
+                if (movableNode?.stickyPose != true) {
+                    userPose = null
+                }
+            }
+        }
+
+        /** Called every time there is a MoveEvent in SceneCore, if this CoreEntity is movable. */
+        private fun updatePoseOnMove(pose: Pose) {
+            if (movableNode?.enabled == false) {
+                return
+            }
+            val node = movableNode ?: return
+
+            // SceneCore uses meters, Compose XR uses pixels.
+            val corePose = pose.convertMetersToPixels(DEFAULT_DENSITY)
+
+            // Find the delta from when the move event started.
+            val coreDeltaPose =
+                Pose(
+                    corePose.translation - initialOffset.translation,
+                    initialOffset.rotation.inverse * corePose.rotation,
+                )
+            if (node.onPoseChange(corePose)) {
+                // We're done, the user app will handle the event.
+                return
+            }
+            userPose = coreDeltaPose
+        }
+
+        /** Flag to enforce single logging of Entity Component update error. */
+        private var shouldLogEnabledCheck: Boolean = true
+
+        /** Log enabled check error if first time occurring. */
+        private fun logEnabledCheck() {
+            if (shouldLogEnabledCheck) {
+                Log.i(
+                    "CoreEntity",
+                    "Not attempting to update Components, functionality is not enabled."
+                )
+                shouldLogEnabledCheck = false
+            }
+        }
+    }
+
+    protected inner class Resizable(private val session: Session) {
+        /**
+         * The node should be resizable.
+         *
+         * Right now this only affects the initial attachment of the component to the Core entity;
+         * however, this may change to be more reactive.
+         *
+         * TODO(b/358678496): Revisit this now that Resizable cannot by disabled, except via the
+         *   'enabled' bit in the Node.
+         */
+        public var isEnabled: Boolean = true
+
+        /** Size based on user adjustments from ResizeEvents from SceneCore. */
+        public var userSize: IntVolumeSize? = null
+            private set(value) {
+                field = value
+                if (value != null) {
+                    // The user size takes priority. Set the current size to the user provided size.
+                    size = value
+                }
+            }
+
+        /**
+         * Update the current state based on the modifiers applied to this [CoreEntity]. If a
+         * [ResizableNode] is present then attach the component and apply its properties. Otherwise,
+         * detach the component. The last matching modifier is used, and earlier modifiers are
+         * ignored.
+         */
+        public fun applyModifiers(nodes: Sequence<SubspaceModifier.Node>) {
+            updateState(nodes.filterIsInstance<ResizableNode>().lastOrNull())
+        }
+
+        /** Sets the size of the SysUI resizable affordance. */
+        public fun setComponentSize(size: IntVolumeSize) {
+            if (isAttached) {
+                component.size = size.toDimensionsInMeters(DEFAULT_DENSITY)
+            }
+        }
+
+        /** All Compose XR params for the Resizable modifier for this CoreEntity. */
+        private var resizableNode: ResizableNode? = null
+
+        /** Whether the resizableComponent is attached to the entity. */
+        private var isAttached: Boolean = false
+
+        private val component: ResizableComponent by lazy {
+            session.createResizableComponent().apply {
+                addResizeListener(
+                    LocalExecutor,
+                    object : ResizeListener {
+                        override fun onResizeStart(entity: Entity, originalSize: Dimensions) {
+                            resizeListener(originalSize)
+                        }
+
+                        // Compose does not need to handle the onResizeUpdate event since Core is
+                        // handling the
+                        // UI affordance change and adding the update would make it so we update the
+                        // size twice.
+                        override fun onResizeEnd(entity: Entity, finalSize: Dimensions) {
+                            resizeListener(finalSize)
+                        }
+                    },
+                )
+            }
+        }
+
+        /**
+         * Updates the resizable state of this CoreEntity. Only update resizable state if
+         * [Resizable] is enabled.
+         *
+         * @param node The Resizable modifier for this CoreEntity.
+         */
+        private fun updateState(node: ResizableNode?) {
+            if (!isEnabled) {
+                if (node != null && node.enabled) logEnabledCheck()
+                return
+            }
+
+            resizableNode = node
+            if (node != null && node.enabled) {
+                enableAndUpdateComponent(node)
+            } else {
+                disableComponent()
+            }
+        }
+
+        /** Flag to enforce single logging of Entity Component update error. */
+        private var shouldLogEnabledCheck: Boolean = true
+
+        /** Log enabled check error if first time occurring. */
+        private fun logEnabledCheck() {
+            if (shouldLogEnabledCheck) {
+                Log.i(
+                    "CoreEntity",
+                    "Not attempting to update Components, entity type is not interactive."
+                )
+                shouldLogEnabledCheck = false
+            }
+        }
+
+        /**
+         * Enables the ResizableComponent for this CoreEntity. Also, updates the component using
+         * [node]'s values.
+         *
+         * @param node The Resizable modifier for this CoreEntity.
+         */
+        private fun enableAndUpdateComponent(node: ResizableNode) {
+            if (!isAttached) {
+                check(entity.addComponent(component)) {
+                    "Could not add ResizableComponent to Core Entity"
+                }
+                // Ensure size is correct, since we do not update the size
+                // when the component is detached.
+                setComponentSize(size)
+                isAttached = true
+            }
+
+            component.minimumSize = node.minimumSize.toDimensionsInMeters()
+            component.maximumSize = node.maximumSize.toDimensionsInMeters()
+
+            component.fixedAspectRatio = if (node.maintainAspectRatio) getAspectRatioY() else 0.0f
+        }
+
+        /** Returns 0.0f if the aspect ratio of x to y is not well defined. */
+        private fun getAspectRatioY(): Float {
+            if (size.width == 0 || size.height == 0) return 0.0f
+            return size.width.toFloat() / size.height.toFloat()
+        }
+
+        /**
+         * Disables the ResizableComponent for this CoreEntity. Takes care of life cycle tasks for
+         * the underlying component in SceneCore.
+         */
+        private fun disableComponent() {
+            if (isAttached) {
+                entity.removeComponent(component)
+                isAttached = false
+            }
+        }
+
+        /**
+         * Called every time there is an onResizeEnd event in SceneCore, if this CoreEntity is
+         * resizable.
+         */
+        private fun resizeListener(newSize: Dimensions) {
+            val node = resizableNode ?: return
+            if (node.onSizeChange(newSize.toIntVolumeSize(DEFAULT_DENSITY))) {
+                // We're done, the user app will handle the event.
+                return
+            }
+            userSize = newSize.toIntVolumeSize(DEFAULT_DENSITY)
+        }
+    }
+}
+
+/**
+ * A [CoreEntityNode] will apply itself to the entity in question as part of the [CoreEntity]'s
+ * application of modifiers. A [CoreEntityNode] should be applicable to all Entity types.
+ *
+ * TODO(b/374774812)
+ */
+internal interface CoreEntityNode {
+    public fun modifyCoreEntity(coreEntity: CoreEntity)
+}
+
+/** Wrapper class for contentless entities from SceneCore. */
+internal class CoreContentlessEntity(entity: Entity) : CoreEntity(entity) {
+    init {
+        require(entity is ContentlessEntity) {
+            "Entity passed to CoreContentlessEntity should be a ContentlessEntity."
+        }
+    }
+}
+
+/**
+ * Wrapper class for [BasePanelEntity] to provide convenience methods for working with panel
+ * entities from SceneCore.
+ */
+internal class CorePanelEntity(session: Session, private val panelEntity: BasePanelEntity<*>) :
+    CoreEntity(panelEntity) {
+
+    init {
+        movable = Movable(session)
+        resizable = Resizable(session)
+    }
+
+    override fun setEntitySize(size: IntVolumeSize) {
+        panelEntity.setPixelDimensions(PixelDimensions(size.width, size.height))
+    }
+
+    /**
+     * Sets a corner radius on all four corners of this PanelEntity.
+     *
+     * @param radius The radius of the corners, in pixels.
+     * @param density The panel pixel density.
+     * @throws IllegalArgumentException if radius is <= 0.0f.
+     */
+    internal fun setCornerRadius(radius: Float, density: Density) {
+        panelEntity.setCornerRadius(Meter.fromPixel(radius, density).value)
+    }
+
+    /**
+     * Returns the corner radius of this PanelEntity, in pixels.
+     *
+     * @param density The panel pixel density.
+     */
+    internal fun getCornerRadius(density: Density): Float {
+        return Meter(panelEntity.getCornerRadius()).toPx(density)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MathExt.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MathExt.kt
new file mode 100644
index 0000000..75d56f5
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MathExt.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.ui.unit.Density
+import androidx.xr.compose.unit.Meter
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+
+/** Converts the translation from pixels to meters, taking into account [density]. */
+internal fun Pose.convertPixelsToMeters(density: Density): Pose =
+    Pose(translation = translation.convertPixelsToMeters(density), rotation = rotation)
+
+/** Converts the translation from meters to pixels, taking into account [density]. */
+internal fun Pose.convertMetersToPixels(density: Density): Pose =
+    Pose(translation = translation.convertMetersToPixels(density), rotation = rotation)
+
+/** Converts values from pixels to meters, taking into account [density]. */
+internal fun Vector3.convertPixelsToMeters(density: Density): Vector3 =
+    Vector3(
+        Meter.fromPixel(x, density).value,
+        Meter.fromPixel(y, density).value,
+        Meter.fromPixel(z, density).value,
+    )
+
+/** Converts values from meters to pixels, taking into account [density]. */
+internal fun Vector3.convertMetersToPixels(density: Density): Vector3 =
+    Vector3(Meter(x).toPx(density), Meter(y).toPx(density), Meter(z).toPx(density))
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Measurable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Measurable.kt
new file mode 100644
index 0000000..9453d3d
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Measurable.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.unit.VolumeConstraints
+
+/**
+ * A part of the composition layout that can be measured.
+ *
+ * Based on [androidx.compose.ui.layout.Measurable].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Measurable {
+    /**
+     * Measures the layout with [VolumeConstraints], returning a [Placeable] layout that has its new
+     * size.
+     */
+    public fun measure(constraints: VolumeConstraints): Placeable
+
+    /** Adjusts layout with a new [ParentLayoutParamsAdjustable]. */
+    public fun adjustParams(params: ParentLayoutParamsAdjustable)
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasurePolicy.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasurePolicy.kt
new file mode 100644
index 0000000..e38b2c1
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasurePolicy.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.unit.VolumeConstraints
+
+/**
+ * Defines the measure and layout behavior of a layout.
+ *
+ * Based on [androidx.compose.ui.layout.MeasurePolicy].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface MeasurePolicy {
+    /**
+     * The function that defines the measurement and layout. Each [Measurable] in the [measurables]
+     * list corresponds to a layout child of the layout, and children can be measured using the
+     * [Measurable.measure] method. This method takes the [VolumeConstraints] which the child should
+     * respect; different children can be measured with different constraints.
+     *
+     * [MeasureResult] objects are usually created using the [MeasureScope.layout] factory, which
+     * takes the calculated size of this layout, its alignment lines, and a block defining the
+     * positioning of the children layouts.
+     */
+    public fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: VolumeConstraints,
+    ): MeasureResult
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureResult.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureResult.kt
new file mode 100644
index 0000000..720a76c
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureResult.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Interface holding the size and alignment lines of the measured layout, as well as the children
+ * positioning logic.
+ *
+ * Based on [androidx.compose.ui.layout.MeasureResult].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface MeasureResult {
+    /** The measured width of the layout, in pixels. */
+    public val width: Int
+
+    /** The measured height of the layout, in pixels. */
+    public val height: Int
+
+    /** The measured depth of the layout, in pixels. */
+    public val depth: Int
+
+    /**
+     * Used for positioning children. [Placeable.placeAt] should be called on children inside
+     * [placeChildren]
+     */
+    public fun placeChildren(placementScope: Placeable.PlacementScope)
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureScope.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureScope.kt
new file mode 100644
index 0000000..cc77532
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/MeasureScope.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import android.content.res.Resources
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Density
+
+/**
+ * The receiver scope of a layout's measure lambda. The return value of the measure lambda is
+ * [MeasureResult], which should be returned by [layout]
+ *
+ * Based on [androidx.compose.ui.layout.MeasureScope].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface MeasureScope : Density {
+
+    /**
+     * Sets the size and alignment lines of the measured layout, as well as the positioning block
+     * that defines the children positioning logic. The [placementBlock] is a lambda used for
+     * positioning children. [Placeable.placeAt] should be called on children inside placementBlock.
+     *
+     * @param width the measured width of the layout, in pixels
+     * @param height the measured height of the layout, in pixels
+     * @param depth the measured depth of the layout, in pixels
+     * @param placementBlock block defining the children positioning of the current layout
+     */
+    public fun layout(
+        width: Int,
+        height: Int,
+        depth: Int,
+        placementBlock: Placeable.PlacementScope.() -> Unit,
+    ): MeasureResult {
+        return object : MeasureResult {
+            override val width = width
+            override val height = height
+            override val depth = depth
+
+            override fun placeChildren(placementScope: Placeable.PlacementScope) {
+                placementScope.placementBlock()
+            }
+        }
+    }
+
+    public override val density: Float
+        get() = Resources.getSystem().displayMetrics.density
+
+    public override val fontScale: Float
+        get() = Resources.getSystem().configuration.fontScale
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Movable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Movable.kt
new file mode 100644
index 0000000..7b8aded
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Movable.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Moves a subspace element (i.e. currently only affects Jetpack XR Entity Panels/Volumes) in space.
+ * The order of the [SubspaceModifier]s is important. Please take note of this when using movable.
+ * If you have the following modifier chain: SubspaceModifier.offset().size().movable(), the
+ * modifiers will work as expected. If instead you have this modifier chain:
+ * SubspaceModifier.size().offset().movable(), you will experience unexpected placement behavior
+ * when using the movable modifier. In general, the offset modifier should be specified before the
+ * size modifier, and the movable modifier should be specified last.
+ *
+ * @param enabled - true if this composable should be movable.
+ * @param stickyPose - if enabled, the user specified position will be retained when the modifier is
+ *   disabled or removed.
+ * @param onPoseChange - a callback to process the pose change during movement, with translation in
+ *   pixels. This will only be called if [enabled] is true. If the callback returns false the
+ *   default behavior of moving this composable's subspace hierarchy will be executed. If it returns
+ *   true, it is the responsibility of the callback to process the event.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.movable(
+    enabled: Boolean = true,
+    stickyPose: Boolean = false,
+    onPoseChange: (Pose) -> Boolean = { false },
+): SubspaceModifier = this.then(MovableElement(enabled, onPoseChange, stickyPose))
+
+private class MovableElement(
+    private val enabled: Boolean,
+    private val onPoseChange: (Pose) -> Boolean,
+    private val stickyPose: Boolean,
+) : SubspaceModifierElement<MovableNode>() {
+
+    override fun create(): MovableNode =
+        MovableNode(enabled = enabled, stickyPose = stickyPose, onPoseChange = onPoseChange)
+
+    override fun update(node: MovableNode) {
+        node.enabled = enabled
+        node.onPoseChange = onPoseChange
+        node.stickyPose = stickyPose
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is MovableElement) return false
+
+        if (enabled != other.enabled) return false
+        if (onPoseChange !== other.onPoseChange) return false
+        if (stickyPose != other.stickyPose) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = enabled.hashCode()
+        result = 31 * result + onPoseChange.hashCode()
+        result = 31 * result + stickyPose.hashCode()
+        return result
+    }
+}
+
+internal class MovableNode(
+    public var enabled: Boolean,
+    public var stickyPose: Boolean,
+    public var onPoseChange: (Pose) -> Boolean,
+) : SubspaceModifier.Node()
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Offset.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Offset.kt
new file mode 100644
index 0000000..df96e06
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Offset.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * Offset the content by ([x] dp, [y] dp, [z] dp). The offsets can be positive as well as
+ * non-positive.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.offset(x: Dp = 0.dp, y: Dp = 0.dp, z: Dp = 0.dp): SubspaceModifier =
+    this then SubspaceOffsetElement(x = x, y = y, z = z)
+
+private class SubspaceOffsetElement(public val x: Dp, public val y: Dp, public val z: Dp) :
+    SubspaceModifierElement<OffsetNode>() {
+    override fun create(): OffsetNode {
+        return OffsetNode(x, y, z)
+    }
+
+    override fun update(node: OffsetNode) {
+        node.x = x
+        node.y = y
+        node.z = z
+    }
+
+    override fun hashCode(): Int {
+        var result = x.hashCode()
+        result = 31 * result + y.hashCode()
+        result = 31 * result + z.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherElement = other as? OffsetNode ?: return false
+
+        return x == otherElement.x && y == otherElement.y && z == otherElement.z
+    }
+}
+
+private class OffsetNode(public var x: Dp, public var y: Dp, public var z: Dp) :
+    SubspaceLayoutModifierNode, SubspaceModifier.Node() {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        val placeable = measurable.measure(constraints)
+        return layout(placeable.measuredWidth, placeable.measuredHeight, placeable.measuredDepth) {
+            placeable.place(
+                Pose(
+                    Vector3(
+                        x.roundToPx().toFloat(),
+                        y.roundToPx().toFloat(),
+                        z.roundToPx().toFloat()
+                    )
+                )
+            )
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifier.kt
new file mode 100644
index 0000000..1756b4d
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifier.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+
+/**
+ * Invoke [onGloballyPositioned] with the [LayoutVolumeCoordinates] of the element when the global
+ * position of the content may have changed. Note that it will be called **after** a composition
+ * when the coordinates are finalized.
+ *
+ * This callback will be invoked at least once when the [LayoutVolumeCoordinates] are available, and
+ * every time the element's position changes within the window. However, it is not guaranteed to be
+ * invoked every time the position _relative to the screen_ of the modified element changes. For
+ * example, the system may move the contents inside a window around without firing a callback. If
+ * you are using the [LayoutVolumeCoordinates] to calculate position on the screen, and not just
+ * inside the window, you may not receive a callback.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.onGloballyPositioned(
+    onGloballyPositioned: (SubspaceLayoutCoordinates) -> Unit
+): SubspaceModifier = this then OnGloballyPositionedVolumeElement(onGloballyPositioned)
+
+private class OnGloballyPositionedVolumeElement(
+    public val onGloballyPositioned: (SubspaceLayoutCoordinates) -> Unit
+) : SubspaceModifierElement<OnGloballyPositionedNode>() {
+    override fun create(): OnGloballyPositionedNode {
+        return OnGloballyPositionedNode(onGloballyPositioned)
+    }
+
+    override fun update(node: OnGloballyPositionedNode) {
+        node.callback = onGloballyPositioned
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is OnGloballyPositionedVolumeElement) return false
+        return onGloballyPositioned === other.onGloballyPositioned
+    }
+
+    override fun hashCode(): Int {
+        return onGloballyPositioned.hashCode()
+    }
+}
+
+/** Node associated with [onGloballyPositioned]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OnGloballyPositionedNode(public var callback: (SubspaceLayoutCoordinates) -> Unit) :
+    SubspaceModifier.Node()
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt
new file mode 100644
index 0000000..46f051e
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Padding.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.compose.unit.constrainDepth
+import androidx.xr.compose.unit.constrainHeight
+import androidx.xr.compose.unit.constrainWidth
+import androidx.xr.compose.unit.offset
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * Apply additional space along each edge of the content in [Dp]: [left], [top], [right], [bottom],
+ * [front] and [back]. Padding is applied before content measurement and takes precedence; content
+ * may only be as large as the remaining space.
+ *
+ * Negative padding is not permitted — it will cause [IllegalArgumentException].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.padding(
+    left: Dp = 0.dp,
+    top: Dp = 0.dp,
+    right: Dp = 0.dp,
+    bottom: Dp = 0.dp,
+    front: Dp = 0.dp,
+    back: Dp = 0.dp,
+): SubspaceModifier =
+    this then
+        SubspacePaddingElement(
+            left = left,
+            top = top,
+            right = right,
+            bottom = bottom,
+            front = front,
+            back = back,
+        )
+
+/**
+ * Apply [horizontal] dp space along the left and right edges of the content, [vertical] dp space
+ * along the top and bottom edges, and [depth] dp space along front and back edged. Padding is
+ * applied before content measurement and takes precedence; content may only be as large as the
+ * remaining space.
+ *
+ * Negative padding is not permitted — it will cause [IllegalArgumentException]. See [padding]
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.padding(
+    horizontal: Dp = 0.dp,
+    vertical: Dp = 0.dp,
+    depth: Dp = 0.dp,
+): SubspaceModifier =
+    this then
+        SubspacePaddingElement(
+            left = horizontal,
+            top = vertical,
+            right = horizontal,
+            bottom = vertical,
+            front = depth,
+            back = depth,
+        )
+
+/**
+ * Apply [all] dp of additional space along each edge of the content, left, top, right, bottom,
+ * front, and back. Padding is applied before content measurement and takes precedence; content may
+ * only be as large as the remaining space.
+ *
+ * Negative padding is not permitted — it will cause [IllegalArgumentException]. See [padding]
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.padding(all: Dp): SubspaceModifier =
+    this then
+        SubspacePaddingElement(
+            left = all,
+            top = all,
+            right = all,
+            bottom = all,
+            front = all,
+            back = all,
+        )
+
+private class SubspacePaddingElement(
+    public val left: Dp,
+    public val top: Dp,
+    public val right: Dp,
+    public val bottom: Dp,
+    public val front: Dp,
+    public val back: Dp,
+) : SubspaceModifierElement<PaddingNode>() {
+
+    init {
+        require(
+            left.value >= 0f &&
+                top.value >= 0f &&
+                right.value >= 0f &&
+                bottom.value >= 0f &&
+                front.value >= 0f &&
+                back.value >= 0f
+        ) {
+            "Padding must be non-negative"
+        }
+    }
+
+    override fun create(): PaddingNode {
+        return PaddingNode(left, top, right, bottom, front, back)
+    }
+
+    override fun update(node: PaddingNode) {
+        node.left = left
+        node.top = top
+        node.right = right
+        node.bottom = bottom
+        node.front = front
+        node.back = back
+    }
+
+    override fun hashCode(): Int {
+        var result = left.hashCode()
+        result = 31 * result + top.hashCode()
+        result = 31 * result + right.hashCode()
+        result = 31 * result + bottom.hashCode()
+        result = 31 * result + front.hashCode()
+        result = 31 * result + back.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherElement = other as? PaddingNode ?: return false
+
+        return left == otherElement.left &&
+            top == otherElement.top &&
+            right == otherElement.right &&
+            bottom == otherElement.bottom &&
+            front == otherElement.front &&
+            back == otherElement.back
+    }
+}
+
+private class PaddingNode(
+    public var left: Dp,
+    public var top: Dp,
+    public var right: Dp,
+    public var bottom: Dp,
+    public var front: Dp,
+    public var back: Dp,
+) : SubspaceLayoutModifierNode, SubspaceModifier.Node() {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        val horizontal = left.roundToPx() + right.roundToPx()
+        val vertical = top.roundToPx() + bottom.roundToPx()
+        val frontAndBack = front.roundToPx() + back.roundToPx()
+
+        val placeable =
+            measurable.measure(constraints.offset(-horizontal, -vertical, -frontAndBack))
+
+        val width = constraints.constrainWidth(placeable.measuredWidth + horizontal)
+        val height = constraints.constrainHeight(placeable.measuredHeight + vertical)
+        val depth = constraints.constrainDepth(placeable.measuredDepth + frontAndBack)
+
+        return layout(width, height, depth) {
+            placeable.place(
+                Pose(
+                    Vector3(
+                        ((left - right) / 2.0f).roundToPx().toFloat(),
+                        ((bottom - top) / 2.0f).roundToPx().toFloat(),
+                        ((back - front) / 2.0f).roundToPx().toFloat(),
+                    )
+                )
+            )
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/ParentLayoutParamsModifier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/ParentLayoutParamsModifier.kt
new file mode 100644
index 0000000..e419fb3
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/ParentLayoutParamsModifier.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+
+/** Interface for classes involved in setting layout params. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ParentLayoutParamsModifier {
+
+    /** Adjusts layout with new [ParentLayoutParamsAdjustable]. */
+    public fun adjustParams(params: ParentLayoutParamsAdjustable)
+}
+
+/** Marker interface for types allowed to be adjusted by a [ParentLayoutParamsModifier]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ParentLayoutParamsAdjustable
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Placeable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Placeable.kt
new file mode 100644
index 0000000..0ca28326
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Placeable.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/**
+ * A [Placeable] corresponds to a child layout that can be positioned by its parent layout. Most
+ * [Placeable]s are the result of a [Measurable.measure] call.
+ *
+ * Based on [androidx.compose.ui.layout.Placeable].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class Placeable {
+    /** The measured width of the layout, in pixels. */
+    public var measuredWidth: Int = 0
+
+    /** The measured height of the layout, in pixels. */
+    public var measuredHeight: Int = 0
+
+    /** The measured depth of the layout, in pixels. */
+    public var measuredDepth: Int = 0
+
+    /** Positions the [Placeable] at [position] in its parent's coordinate system. */
+    protected abstract fun placeAt(pose: Pose)
+
+    /** Receiver scope that permits explicit placement of a [Placeable]. */
+    public abstract class PlacementScope {
+        /**
+         * The [SubspaceLayoutCoordinates] of this layout, if known or `null` if the layout hasn't
+         * been placed yet.
+         */
+        public open val coordinates: SubspaceLayoutCoordinates?
+            get() = null
+
+        /** Place a [Placeable] at the [Pose] in its parent's coordinate system. */
+        public fun Placeable.place(pose: Pose) {
+            placeAt(pose)
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Resizable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Resizable.kt
new file mode 100644
index 0000000..0598b4f
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Resizable.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Dp
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.compose.unit.IntVolumeSize
+
+/**
+ * Resize a subspace element (i.e. currently only affects Jetpack XR Entity Panels/Volumes) in
+ * space.
+ *
+ * @param enabled - true if this composable should be resizable.
+ * @param minimumSize - the smallest allowed dimensions for this composable.
+ * @param maximumSize - the largest allowed dimensions for this composable.
+ * @param maintainAspectRatio - true if the new size should maintain the same aspect ratio as the
+ *   existing size.
+ * @param onSizeChange - a callback to process the size change during resizing. This will only be
+ *   called if [enabled] is true. If the callback returns false the default behavior of resizing
+ *   this composable will be executed. If it returns true, it is the responsibility of the callback
+ *   to process the event.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.resizable(
+    enabled: Boolean = true,
+    minimumSize: DpVolumeSize = DpVolumeSize.Zero,
+    maximumSize: DpVolumeSize = DpVolumeSize(Dp.Infinity, Dp.Infinity, Dp.Infinity),
+    maintainAspectRatio: Boolean = false,
+    onSizeChange: (IntVolumeSize) -> Boolean = { false },
+): SubspaceModifier =
+    this.then(
+        ResizableElement(enabled, minimumSize, maximumSize, maintainAspectRatio, onSizeChange)
+    )
+
+private class ResizableElement(
+    private val enabled: Boolean,
+    private val minimumSize: DpVolumeSize,
+    private val maximumSize: DpVolumeSize,
+    private val maintainAspectRatio: Boolean,
+    private val onSizeChange: (IntVolumeSize) -> Boolean,
+) : SubspaceModifierElement<ResizableNode>() {
+
+    init {
+        // TODO(b/345303299): Decide on implementation for min/max size bound checking against
+        // current
+        //  size.
+        require(
+            minimumSize.depth <= maximumSize.depth &&
+                minimumSize.height <= maximumSize.height &&
+                minimumSize.width <= maximumSize.width
+        ) {
+            "minimumSize must be less than or equal to maximumSize"
+        }
+    }
+
+    override fun create(): ResizableNode =
+        ResizableNode(enabled, minimumSize, maximumSize, maintainAspectRatio, onSizeChange)
+
+    override fun update(node: ResizableNode) {
+        node.enabled = enabled
+        node.minimumSize = minimumSize
+        node.maximumSize = maximumSize
+        node.maintainAspectRatio = maintainAspectRatio
+        node.onSizeChange = onSizeChange
+    }
+
+    override fun hashCode(): Int {
+        var result = enabled.hashCode()
+        result = 31 * result + minimumSize.hashCode()
+        result = 31 * result + maximumSize.hashCode()
+        result = 31 * result + maintainAspectRatio.hashCode()
+        result = 31 * result + onSizeChange.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        val otherElement = other as? ResizableNode ?: return false
+
+        return enabled == otherElement.enabled &&
+            minimumSize == otherElement.minimumSize &&
+            maximumSize == otherElement.maximumSize &&
+            maintainAspectRatio == otherElement.maintainAspectRatio &&
+            onSizeChange === otherElement.onSizeChange
+    }
+}
+
+internal class ResizableNode(
+    internal var enabled: Boolean,
+    internal var minimumSize: DpVolumeSize,
+    internal var maximumSize: DpVolumeSize,
+    internal var maintainAspectRatio: Boolean,
+    internal var onSizeChange: (IntVolumeSize) -> Boolean,
+) : SubspaceModifier.Node()
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Rotate.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Rotate.kt
new file mode 100644
index 0000000..d5ce88a
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Rotate.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * Rotate a subspace element (i.e. Panel) in space. Parameter rotation angles are specified in
+ * degrees. The rotations are applied with the order pitch, then yaw, then roll.
+ *
+ * @param pitch Rotation around the x-axis. The x-axis is the axis width is measured on.
+ * @param yaw Rotation around the y-axis. The y-axis is the axis height is measured on.
+ * @param roll Rotation around the z-axis. The z-axis is the axis depth is measured on.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.rotate(pitch: Float, yaw: Float, roll: Float): SubspaceModifier =
+    this.then(RotationElement(pitch, yaw, roll))
+
+/**
+ * Rotate a subspace element (i.e. Panel) in space.
+ *
+ * @param axisAngle Vector representing the axis of rotation.
+ * @param rotation Degrees of rotation.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.rotate(axisAngle: Vector3, rotation: Float): SubspaceModifier =
+    this.then(RotationElement(axisAngle, rotation))
+
+/**
+ * Rotate a subspace element (i.e. Panel) in space.
+ *
+ * @param quaternion Quaternion describing the rotation.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.rotate(quaternion: Quaternion): SubspaceModifier =
+    this.then(RotationElement(quaternion))
+
+private class RotationElement(private val quaternion: Quaternion) :
+    SubspaceModifierElement<RotationNode>() {
+
+    public constructor(
+        pitch: Float,
+        yaw: Float,
+        roll: Float,
+    ) : this(Quaternion.fromEulerAngles(pitch, yaw, roll))
+
+    public constructor(
+        axisAngle: Vector3,
+        rotation: Float,
+    ) : this(Quaternion.fromAxisAngle(axisAngle, rotation))
+
+    override fun create(): RotationNode = RotationNode(quaternion)
+
+    override fun hashCode(): Int {
+        return quaternion.hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is RotationElement) return false
+
+        if (quaternion != other.quaternion) return false
+
+        return true
+    }
+
+    override fun update(node: RotationNode) {
+        node.quaternion = quaternion
+    }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class RotationNode(public var quaternion: Quaternion) :
+    SubspaceLayoutModifierNode, SubspaceModifier.Node() {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        val placeable = measurable.measure(constraints)
+        return layout(placeable.measuredWidth, placeable.measuredHeight, placeable.measuredDepth) {
+            placeable.place(Pose(translation = Vector3.Zero, rotation = quaternion))
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Scale.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Scale.kt
new file mode 100644
index 0000000..d8eb2a2
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Scale.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+
+/**
+ * Scale the contents of the composable by the scale factor along horizontal, vertical, and depth
+ * axes. Scaling does not change the measured size of the composable content during layout. Measured
+ * size of @SubspaceComposable elements can be controlled using Size Modifiers. Scale factor should
+ * be a positive number.
+ *
+ * @param scale - Multiplier to scale content along vertical, horizontal, depth axes.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.scale(scale: Float): SubspaceModifier = this.then(ScaleElement(scale))
+
+private class ScaleElement(private val scale: Float) : SubspaceModifierElement<ScaleNode>() {
+
+    init {
+        require(scale > 0.0f) { "scale values must be > 0.0f" }
+    }
+
+    override fun create(): ScaleNode = ScaleNode(scale)
+
+    override fun update(node: ScaleNode) {
+        node.scale = scale
+    }
+
+    override fun hashCode(): Int {
+        return scale.hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is ScaleElement) return false
+
+        return scale == other.scale
+    }
+}
+
+private class ScaleNode(public var scale: Float) : SubspaceModifier.Node(), CoreEntityNode {
+    override fun modifyCoreEntity(coreEntity: CoreEntity) {
+        coreEntity.scale = scale
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SemanticsModifier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SemanticsModifier.kt
new file mode 100644
index 0000000..64a8aab
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SemanticsModifier.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.subspace.node.SubspaceSemanticsModifierNode
+
+/**
+ * Add semantics key/value pairs to the layout node, for use in testing, accessibility, etc.
+ *
+ * Based on [androidx.compose.ui.semantics.SemanticsModifier].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.semantics(
+    properties: (SemanticsPropertyReceiver.() -> Unit)
+): SubspaceModifier = this then AppendedSemanticsElement(properties = properties)
+
+private class AppendedSemanticsElement(
+    private val properties: (SemanticsPropertyReceiver.() -> Unit)
+) : SubspaceModifierElement<SemanticsModifierNode>() {
+
+    override fun create(): SemanticsModifierNode {
+        return SemanticsModifierNode(properties = properties)
+    }
+
+    override fun update(node: SemanticsModifierNode) {
+        node.properties = properties
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is AppendedSemanticsElement) return false
+        return properties === other.properties
+    }
+
+    override fun hashCode(): Int {
+        return properties.hashCode()
+    }
+}
+
+private class SemanticsModifierNode(public var properties: SemanticsPropertyReceiver.() -> Unit) :
+    SubspaceModifier.Node(), SubspaceSemanticsModifierNode {
+    override fun SemanticsPropertyReceiver.applySemantics() {
+        properties()
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Size.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Size.kt
new file mode 100644
index 0000000..95bbb02
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/Size.kt
@@ -0,0 +1,527 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.FloatRange
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.compose.unit.constrain
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+
+/** Declare the preferred size of the content to be exactly [width] dp along the x dimension. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.width(width: Dp): SubspaceModifier =
+    this.then(SizeElement(minWidth = width, maxWidth = width, enforceIncoming = true))
+
+/** Declare the preferred size of the content to be exactly [height] dp along the y dimension. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.height(height: Dp): SubspaceModifier =
+    this.then(SizeElement(minHeight = height, maxHeight = height, enforceIncoming = true))
+
+/**
+ * Declare the preferred size of the content to be exactly [depth] dp along the z dimension. Panels
+ * have 0 depth and ignore this modifier.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.depth(depth: Dp): SubspaceModifier =
+    this.then(SizeElement(minDepth = depth, maxDepth = depth, enforceIncoming = true))
+
+/**
+ * Declare the preferred size of the content to be exactly a [size] dp cube. When applied to a
+ * Panel, the preferred size will be a [size] dp square instead.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.size(size: Dp): SubspaceModifier =
+    this.then(
+        SizeElement(
+            minWidth = size,
+            maxWidth = size,
+            minHeight = size,
+            maxHeight = size,
+            minDepth = size,
+            maxDepth = size,
+            enforceIncoming = true,
+        )
+    )
+
+/**
+ * Declare the preferred size of the content to be exactly [size] in each of the three dimensions.
+ * Panels have 0 depth and ignore the z-component of this modifier.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.size(size: DpVolumeSize): SubspaceModifier =
+    this.then(
+        SizeElement(
+            minWidth = size.width,
+            maxWidth = size.width,
+            minHeight = size.height,
+            maxHeight = size.height,
+            minDepth = size.depth,
+            maxDepth = size.depth,
+            enforceIncoming = true,
+        )
+    )
+
+/** Declare the size of the content to be exactly [width] dp along the x dimension. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.requiredWidth(width: Dp): SubspaceModifier =
+    this.then(SizeElement(minWidth = width, maxWidth = width, enforceIncoming = false))
+
+/** Declare the size of the content to be exactly [height] dp along the y dimension. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.requiredHeight(height: Dp): SubspaceModifier =
+    this.then(SizeElement(minHeight = height, maxHeight = height, enforceIncoming = false))
+
+/**
+ * Declare the size of the content to be exactly [depth] dp along the z dimension. Panels have 0
+ * depth and ignore this modifier.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.requiredDepth(depth: Dp): SubspaceModifier =
+    this.then(SizeElement(minDepth = depth, maxDepth = depth, enforceIncoming = false))
+
+/**
+ * Declare the size of the content to be exactly a [size] dp cube. When applied to a Panel, the size
+ * will be a [size] dp square instead.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.requiredSize(size: Dp): SubspaceModifier =
+    this.then(
+        SizeElement(
+            minWidth = size,
+            maxWidth = size,
+            minHeight = size,
+            maxHeight = size,
+            minDepth = size,
+            maxDepth = size,
+            enforceIncoming = false,
+        )
+    )
+
+/**
+ * Declare the size of the content to be exactly [size] in each of the three dimensions. Panels have
+ * 0 depth and ignore the z-component of this modifier.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.requiredSize(size: DpVolumeSize): SubspaceModifier =
+    this.then(
+        SizeElement(
+            minWidth = size.width,
+            maxWidth = size.width,
+            minHeight = size.height,
+            maxHeight = size.height,
+            minDepth = size.depth,
+            maxDepth = size.depth,
+            enforceIncoming = false,
+        )
+    )
+
+/**
+ * Have the content fill (possibly only partially) the [VolumeConstraints.maxWidth] of the incoming
+ * measurement constraints, by setting the [minimum width][VolumeConstraints.minWidth] and the
+ * [maximum width][VolumeConstraints.maxWidth] to be equal to the
+ * [maximum width][VolumeConstraints.maxWidth] multiplied by [fraction]. Note that, by default, the
+ * [fraction] is 1, so the modifier will make the content fill the whole available width. If the
+ * incoming maximum width is [VolumeConstraints.Infinity] this modifier will have no effect.
+ *
+ * @param fraction The fraction of the maximum width to use, between `0` and `1`, inclusive.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.fillMaxWidth(
+    @FloatRange(from = 0.0, to = 1.0) fraction: Float = 1f
+): SubspaceModifier =
+    this.then(if (fraction == 1f) FillWholeMaxWidth else FillElement.width(fraction))
+
+private val FillWholeMaxWidth = FillElement.width(1f)
+
+/**
+ * Have the content fill (possibly only partially) the [VolumeConstraints.maxHeight] of the incoming
+ * measurement constraints, by setting the [minimum height][VolumeConstraints.minHeight] and the
+ * [maximum height][VolumeConstraints.maxHeight] to be equal to the
+ * [maximum height][VolumeConstraints.maxHeight] multiplied by [fraction]. Note that, by default,
+ * the [fraction] is 1, so the modifier will make the content fill the whole available height. If
+ * the incoming maximum height is [VolumeConstraints.Infinity] this modifier will have no effect.
+ *
+ * @param fraction The fraction of the maximum height to use, between `0` and `1`, inclusive.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.fillMaxHeight(
+    @FloatRange(from = 0.0, to = 1.0) fraction: Float = 1f
+): SubspaceModifier =
+    this.then(if (fraction == 1f) FillWholeMaxHeight else FillElement.height(fraction))
+
+private val FillWholeMaxHeight = FillElement.height(1f)
+
+/**
+ * Have the content fill (possibly only partially) the [VolumeConstraints.maxDepth] of the incoming
+ * measurement constraints, by setting the [minimum depth][VolumeConstraints.minDepth] and the
+ * [maximum depth][VolumeConstraints.maxDepth] to be equal to the
+ * [maximum depth][VolumeConstraints.maxDepth] multiplied by [fraction]. Note that, by default, the
+ * [fraction] is 1, so the modifier will make the content fill the whole available depth. If the
+ * incoming maximum depth is [VolumeConstraints.Infinity] this modifier will have no effect.
+ *
+ * @param fraction The fraction of the maximum height to use, between `0` and `1`, inclusive.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.fillMaxDepth(
+    @FloatRange(from = 0.0, to = 1.0) fraction: Float = 1f
+): SubspaceModifier =
+    this.then(if (fraction == 1f) FillWholeMaxDepth else FillElement.depth(fraction))
+
+private val FillWholeMaxDepth = FillElement.depth(1f)
+
+/**
+ * Have the content fill (possibly only partially) the [VolumeConstraints.maxWidth],
+ * [VolumeConstraints.maxHeight], and [VolumeConstraints.maxDepth] of the incoming measurement
+ * constraints. See [SubspaceModifier.fillMaxWidth], [SubspaceModifier.fillMaxHeight], and
+ * [SubspaceModifier.fillMaxDepth] for details. Note that, by default, the [fraction] is 1, so the
+ * modifier will make the content fill the whole available space. If the incoming maximum width or
+ * height or depth is [VolumeConstraints.Infinity] this modifier will have no effect in that
+ * dimension.
+ *
+ * @param fraction The fraction of the maximum size to use, between `0` and `1`, inclusive.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.fillMaxSize(
+    @FloatRange(from = 0.0, to = 1.0) fraction: Float = 1f
+): SubspaceModifier =
+    this.then(if (fraction == 1f) FillWholeMaxSize else FillElement.size(fraction))
+
+private val FillWholeMaxSize = FillElement.size(1f)
+
+private class FillElement(
+    private val direction: Direction,
+    private val fraction: Float,
+    private val inspectorName: String,
+) : SubspaceModifierElement<FillNode>() {
+    override fun create(): FillNode = FillNode(direction = direction, fraction = fraction)
+
+    override fun update(node: FillNode) {
+        node.direction = direction
+        node.fraction = fraction
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is FillElement) return false
+
+        if (direction != other.direction) return false
+        if (fraction != other.fraction) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = direction.hashCode()
+        result = 31 * result + fraction.hashCode()
+        return result
+    }
+
+    @Suppress("ModifierFactoryExtensionFunction", "ModifierFactoryReturnType")
+    public companion object {
+        public fun width(fraction: Float) =
+            FillElement(
+                direction = Direction.X,
+                fraction = fraction,
+                inspectorName = "fillMaxWidth"
+            )
+
+        public fun height(fraction: Float) =
+            FillElement(
+                direction = Direction.Y,
+                fraction = fraction,
+                inspectorName = "fillMaxHeight"
+            )
+
+        public fun depth(fraction: Float) =
+            FillElement(
+                direction = Direction.Z,
+                fraction = fraction,
+                inspectorName = "fillMaxDepth"
+            )
+
+        public fun size(fraction: Float) =
+            FillElement(
+                direction = Direction.AllThree,
+                fraction = fraction,
+                inspectorName = "fillMaxSize",
+            )
+    }
+}
+
+private class FillNode(public var direction: Direction, public var fraction: Float) :
+    SubspaceLayoutModifierNode, SubspaceModifier.Node() {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        val minWidth: Int
+        val maxWidth: Int
+        if (
+            constraints.hasBoundedWidth &&
+                (direction == Direction.X || direction == Direction.AllThree)
+        ) {
+            val width =
+                (constraints.maxWidth * fraction)
+                    .fastRoundToInt()
+                    .coerceIn(constraints.minWidth, constraints.maxWidth)
+            minWidth = width
+            maxWidth = width
+        } else {
+            minWidth = constraints.minWidth
+            maxWidth = constraints.maxWidth
+        }
+        val minHeight: Int
+        val maxHeight: Int
+        if (
+            constraints.hasBoundedHeight &&
+                (direction == Direction.Y || direction == Direction.AllThree)
+        ) {
+            val height =
+                (constraints.maxHeight * fraction)
+                    .fastRoundToInt()
+                    .coerceIn(constraints.minHeight, constraints.maxHeight)
+            minHeight = height
+            maxHeight = height
+        } else {
+            minHeight = constraints.minHeight
+            maxHeight = constraints.maxHeight
+        }
+        val minDepth: Int
+        val maxDepth: Int
+        if (
+            constraints.hasBoundedDepth &&
+                (direction == Direction.Z || direction == Direction.AllThree)
+        ) {
+            val depth =
+                (constraints.maxDepth * fraction)
+                    .fastRoundToInt()
+                    .coerceIn(constraints.minDepth, constraints.maxDepth)
+            minDepth = depth
+            maxDepth = depth
+        } else {
+            minDepth = constraints.minDepth
+            maxDepth = constraints.maxDepth
+        }
+        val placeable =
+            measurable.measure(
+                VolumeConstraints(minWidth, maxWidth, minHeight, maxHeight, minDepth, maxDepth)
+            )
+
+        return layout(placeable.measuredWidth, placeable.measuredHeight, placeable.measuredDepth) {
+            placeable.place(Pose(translation = Vector3.Zero, rotation = Quaternion.Identity))
+        }
+    }
+}
+
+private class SizeElement(
+    private val minWidth: Dp = Dp.Unspecified,
+    private val maxWidth: Dp = Dp.Unspecified,
+    private val minHeight: Dp = Dp.Unspecified,
+    private val maxHeight: Dp = Dp.Unspecified,
+    private val minDepth: Dp = Dp.Unspecified,
+    private val maxDepth: Dp = Dp.Unspecified,
+    private val enforceIncoming: Boolean,
+) : SubspaceModifierElement<SizeNode>() {
+    override fun create(): SizeNode =
+        SizeNode(
+            minWidth = minWidth,
+            minHeight = minHeight,
+            maxWidth = maxWidth,
+            maxHeight = maxHeight,
+            minDepth = minDepth,
+            maxDepth = maxDepth,
+            enforceIncoming = enforceIncoming,
+        )
+
+    override fun hashCode(): Int {
+        var result = minWidth.hashCode()
+        result = 31 * result + minHeight.hashCode()
+        result = 31 * result + maxWidth.hashCode()
+        result = 31 * result + maxHeight.hashCode()
+        result = 31 * result + minDepth.hashCode()
+        result = 31 * result + maxDepth.hashCode()
+        result = 31 * result + enforceIncoming.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SizeElement) return false
+
+        if (minWidth != other.minWidth) return false
+        if (minHeight != other.minHeight) return false
+        if (maxWidth != other.maxWidth) return false
+        if (maxHeight != other.maxHeight) return false
+        if (minDepth != other.minDepth) return false
+        if (maxDepth != other.maxDepth) return false
+        if (enforceIncoming != other.enforceIncoming) return false
+
+        return true
+    }
+
+    override fun update(node: SizeNode) {
+        node.minWidth = minWidth
+        node.minHeight = minHeight
+        node.maxWidth = maxWidth
+        node.maxHeight = maxHeight
+        node.minDepth = minDepth
+        node.maxDepth = maxDepth
+        node.enforceIncoming = enforceIncoming
+    }
+}
+
+private class SizeNode(
+    public var minWidth: Dp = Dp.Unspecified,
+    public var maxWidth: Dp = Dp.Unspecified,
+    public var minHeight: Dp = Dp.Unspecified,
+    public var maxHeight: Dp = Dp.Unspecified,
+    public var minDepth: Dp = Dp.Unspecified,
+    public var maxDepth: Dp = Dp.Unspecified,
+    public var enforceIncoming: Boolean,
+) : SubspaceLayoutModifierNode, SubspaceModifier.Node() {
+
+    private val MeasureScope.targetConstraints: VolumeConstraints
+        get() {
+            val maxWidth =
+                if (maxWidth != Dp.Unspecified) {
+                    maxWidth.roundToPx().coerceAtLeast(0)
+                } else {
+                    VolumeConstraints.INFINITY
+                }
+            val maxHeight =
+                if (maxHeight != Dp.Unspecified) {
+                    maxHeight.roundToPx().coerceAtLeast(0)
+                } else {
+                    VolumeConstraints.INFINITY
+                }
+            val maxDepth =
+                if (maxDepth != Dp.Unspecified) {
+                    maxDepth.roundToPx().coerceAtLeast(0)
+                } else {
+                    VolumeConstraints.INFINITY
+                }
+            val minWidth =
+                if (minWidth != Dp.Unspecified) {
+                    minWidth.roundToPx().coerceAtMost(maxWidth).coerceAtLeast(0).let {
+                        if (it != VolumeConstraints.INFINITY) it else 0
+                    }
+                } else {
+                    0
+                }
+            val minHeight =
+                if (minHeight != Dp.Unspecified) {
+                    minHeight.roundToPx().coerceAtMost(maxHeight).coerceAtLeast(0).let {
+                        if (it != VolumeConstraints.INFINITY) it else 0
+                    }
+                } else {
+                    0
+                }
+            val minDepth =
+                if (minDepth != Dp.Unspecified) {
+                    minDepth.roundToPx().coerceAtMost(maxDepth).coerceAtLeast(0).let {
+                        if (it != VolumeConstraints.INFINITY) it else 0
+                    }
+                } else {
+                    0
+                }
+            return VolumeConstraints(
+                minWidth = minWidth,
+                minHeight = minHeight,
+                maxWidth = maxWidth,
+                maxHeight = maxHeight,
+                minDepth = minDepth,
+                maxDepth = maxDepth,
+            )
+        }
+
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        val wrappedConstraints =
+            targetConstraints.let {
+                if (enforceIncoming) {
+                    constraints.constrain(targetConstraints)
+                } else {
+                    val resolvedMinWidth =
+                        if (minWidth != Dp.Unspecified) {
+                            targetConstraints.minWidth
+                        } else {
+                            constraints.minWidth.coerceAtMost(targetConstraints.maxWidth)
+                        }
+                    val resolvedMaxWidth =
+                        if (maxWidth != Dp.Unspecified) {
+                            targetConstraints.maxWidth
+                        } else {
+                            constraints.maxWidth.coerceAtLeast(targetConstraints.minWidth)
+                        }
+                    val resolvedMinHeight =
+                        if (minHeight != Dp.Unspecified) {
+                            targetConstraints.minHeight
+                        } else {
+                            constraints.minHeight.coerceAtMost(targetConstraints.maxHeight)
+                        }
+                    val resolvedMaxHeight =
+                        if (maxHeight != Dp.Unspecified) {
+                            targetConstraints.maxHeight
+                        } else {
+                            constraints.maxHeight.coerceAtLeast(targetConstraints.minHeight)
+                        }
+                    val resolvedMinDepth =
+                        if (minDepth != Dp.Unspecified) {
+                            targetConstraints.minDepth
+                        } else {
+                            constraints.minDepth.coerceAtMost(targetConstraints.maxDepth)
+                        }
+                    val resolvedMaxDepth =
+                        if (maxDepth != Dp.Unspecified) {
+                            targetConstraints.maxDepth
+                        } else {
+                            constraints.maxDepth.coerceAtLeast(targetConstraints.minDepth)
+                        }
+                    VolumeConstraints(
+                        resolvedMinWidth,
+                        resolvedMaxWidth,
+                        resolvedMinHeight,
+                        resolvedMaxHeight,
+                        resolvedMinDepth,
+                        resolvedMaxDepth,
+                    )
+                }
+            }
+
+        val placeable = measurable.measure(wrappedConstraints)
+        return layout(placeable.measuredWidth, placeable.measuredHeight, placeable.measuredDepth) {
+            placeable.place(Pose())
+        }
+    }
+}
+
+internal enum class Direction {
+    X,
+    Y,
+    Z,
+    AllThree,
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignment.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignment.kt
new file mode 100644
index 0000000..bb44eca
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignment.kt
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Vector3
+import kotlin.math.roundToInt
+
+/**
+ * An interface to calculate the position of a sized box inside of an available 3D space.
+ * [SpatialAlignment] is often used to define the alignment of a layout inside a parent layout.
+ *
+ * @see SpatialBiasAlignment
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialAlignment {
+    /**
+     * Provides the horizontal offset from the origin of the space to the origin of the content.
+     *
+     * @param width The content width in pixels.
+     * @param space The available space in pixels.
+     */
+    public fun horizontalOffset(width: Int, space: Int): Int
+
+    /**
+     * Provides the vertical offset from the origin of the space to the origin of the content.
+     *
+     * @param height The content height in pixels.
+     * @param space The available space in pixels.
+     */
+    public fun verticalOffset(height: Int, space: Int): Int
+
+    /**
+     * Provides the depth offset from the origin of the space to the origin of the content.
+     *
+     * @param depth The content depth in pixels.
+     * @param space The available space in pixels.
+     */
+    public fun depthOffset(depth: Int, space: Int): Int
+
+    /**
+     * Provides the origin-based position of the content in the available space.
+     *
+     * @param size The content size in pixels.
+     * @param space The available space in pixels.
+     */
+    public fun position(size: IntVolumeSize, space: IntVolumeSize): Vector3
+
+    /**
+     * An interface to calculate the position of a box of a certain width inside an available width.
+     */
+    public interface Horizontal {
+        /** @see horizontalOffset */
+        public fun offset(width: Int, space: Int): Int
+    }
+
+    /**
+     * An interface to calculate the position of a box of a certain height inside an available
+     * height.
+     */
+    public interface Vertical {
+        /** @see verticalOffset */
+        public fun offset(height: Int, space: Int): Int
+    }
+
+    /**
+     * An interface to calculate the position of a box of a certain depth inside an available depth.
+     */
+    public interface Depth {
+        /** @see depthOffset */
+        public fun offset(depth: Int, space: Int): Int
+    }
+
+    public companion object {
+        // 2D alignments
+        @JvmStatic public val TopLeft: SpatialAlignment = SpatialBiasAlignment(-1f, 1f, 0f)
+        @JvmStatic public val TopCenter: SpatialAlignment = SpatialBiasAlignment(0f, 1f, 0f)
+        @JvmStatic public val TopRight: SpatialAlignment = SpatialBiasAlignment(1f, 1f, 0f)
+        @JvmStatic public val CenterLeft: SpatialAlignment = SpatialBiasAlignment(-1f, 0f, 0f)
+        @JvmStatic public val Center: SpatialAlignment = SpatialBiasAlignment(0f, 0f, 0f)
+        @JvmStatic public val CenterRight: SpatialAlignment = SpatialBiasAlignment(1f, 0f, 0f)
+        @JvmStatic public val BottomLeft: SpatialAlignment = SpatialBiasAlignment(-1f, -1f, 0f)
+        @JvmStatic public val BottomCenter: SpatialAlignment = SpatialBiasAlignment(0f, -1f, 0f)
+        @JvmStatic public val BottomRight: SpatialAlignment = SpatialBiasAlignment(1f, -1f, 0f)
+
+        // Horizontal alignments
+        @JvmStatic
+        public val Left: SpatialBiasAlignment.Horizontal = SpatialBiasAlignment.Horizontal(-1f)
+        @JvmStatic
+        public val CenterHorizontally: SpatialBiasAlignment.Horizontal =
+            SpatialBiasAlignment.Horizontal(0f)
+        @JvmStatic
+        public val Right: SpatialBiasAlignment.Horizontal = SpatialBiasAlignment.Horizontal(1f)
+
+        // Vertical alignments
+        @JvmStatic
+        public val Bottom: SpatialBiasAlignment.Vertical = SpatialBiasAlignment.Vertical(-1f)
+        @JvmStatic
+        public val CenterVertically: SpatialBiasAlignment.Vertical =
+            SpatialBiasAlignment.Vertical(0f)
+        @JvmStatic public val Top: SpatialBiasAlignment.Vertical = SpatialBiasAlignment.Vertical(1f)
+
+        // Depth alignments
+        @JvmStatic public val Back: SpatialBiasAlignment.Depth = SpatialBiasAlignment.Depth(-1f)
+        @JvmStatic
+        public val CenterDepthwise: SpatialBiasAlignment.Depth = SpatialBiasAlignment.Depth(0f)
+        @JvmStatic public val Front: SpatialBiasAlignment.Depth = SpatialBiasAlignment.Depth(1f)
+    }
+}
+
+/**
+ * Creates a weighted alignment that specifies a horizontal, vertical, and depth bias.
+ *
+ * @param horizontalBias Must be within the range of [-1, 1] with -1 being left and 1 being right.
+ * @param verticalBias Must be within the range of [-1, 1] with -1 being bottom and 1 being top.
+ * @param depthBias Must be within the range of [-1, 1] with -1 being back and 1 being front.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialBiasAlignment(
+    public val horizontalBias: Float,
+    public val verticalBias: Float,
+    public val depthBias: Float,
+) : SpatialAlignment {
+    override fun horizontalOffset(width: Int, space: Int): Int =
+        offset(horizontalBias, width, space)
+
+    override fun verticalOffset(height: Int, space: Int): Int = offset(verticalBias, height, space)
+
+    override fun depthOffset(depth: Int, space: Int): Int = offset(depthBias, depth, space)
+
+    override fun position(size: IntVolumeSize, space: IntVolumeSize): Vector3 =
+        Vector3(
+            horizontalOffset(size.width, space.width).toFloat(),
+            verticalOffset(size.height, space.height).toFloat(),
+            depthOffset(size.depth, space.depth).toFloat(),
+        )
+
+    public fun copy(
+        horizontalBias: Float = this.horizontalBias,
+        verticalBias: Float = this.verticalBias,
+        depthBias: Float = this.depthBias,
+    ): SpatialBiasAlignment =
+        SpatialBiasAlignment(
+            horizontalBias = horizontalBias,
+            verticalBias = verticalBias,
+            depthBias = depthBias,
+        )
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SpatialBiasAlignment) return false
+
+        if (horizontalBias != other.horizontalBias) return false
+        if (verticalBias != other.verticalBias) return false
+        if (depthBias != other.depthBias) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = horizontalBias.hashCode()
+        result = 31 * result + verticalBias.hashCode()
+        result = 31 * result + depthBias.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "SpatialBiasAlignment(horizontalBias=$horizontalBias, verticalBias=$verticalBias, depthBias=$depthBias)"
+    }
+
+    /**
+     * Creates a weighted alignment that specifies a horizontal bias.
+     *
+     * @param bias Must be within the range of [-1, 1] with -1 being left and 1 being right.
+     */
+    public class Horizontal(public val bias: Float) : SpatialAlignment.Horizontal {
+        override fun offset(width: Int, space: Int): Int = offset(bias, width, space)
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is Horizontal) return false
+
+            if (bias != other.bias) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            return bias.hashCode()
+        }
+
+        override fun toString(): String {
+            return "Horizontal(bias=$bias)"
+        }
+
+        public fun copy(bias: Float = this.bias): Horizontal = Horizontal(bias = bias)
+    }
+
+    /**
+     * Creates a weighted alignment that specifies a vertical bias.
+     *
+     * @param bias Must be within the range of [-1, 1] with -1 being bottom and 1 being top.
+     */
+    public class Vertical(public val bias: Float) : SpatialAlignment.Vertical {
+        override fun offset(height: Int, space: Int): Int = offset(bias, height, space)
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is Vertical) return false
+
+            if (bias != other.bias) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            return bias.hashCode()
+        }
+
+        override fun toString(): String {
+            return "Vertical(bias=$bias)"
+        }
+
+        public fun copy(bias: Float = this.bias): Vertical = Vertical(bias = bias)
+    }
+
+    /**
+     * Creates a weighted alignment that specifies a depth bias.
+     *
+     * @param bias Must be within the range of [-1, 1] with -1 being back and 1 being front.
+     */
+    public class Depth(public val bias: Float) : SpatialAlignment.Depth {
+        override fun offset(depth: Int, space: Int): Int = offset(bias, depth, space)
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is Depth) return false
+
+            if (bias != other.bias) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            return bias.hashCode()
+        }
+
+        override fun toString(): String {
+            return "Depth(bias=$bias)"
+        }
+
+        public fun copy(bias: Float = this.bias): Depth = Depth(bias = bias)
+    }
+
+    public companion object {
+        private fun offset(bias: Float, size: Int, space: Int): Int =
+            ((space - size) / 2.0f * bias).roundToInt()
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialShape.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialShape.kt
new file mode 100644
index 0000000..8096cf9
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SpatialShape.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.unit.Density
+
+/** Base Spatial shape. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class SpatialShape
+
+/** A shape describing a rectangle with rounded corners in 3D space. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialRoundedCornerShape(private val size: CornerSize) : SpatialShape() {
+    /**
+     * Computes corner radius to be no larger than 50 percent of the smallest side.
+     *
+     * @return The corner radius in pixels.
+     */
+    internal fun computeCornerRadius(maxWidth: Float, maxHeight: Float, density: Density): Float {
+        return size
+            .toPx(Size(maxWidth, maxHeight), density)
+            .coerceAtMost(maxWidth / 2f)
+            .coerceAtMost(maxHeight / 2f)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
new file mode 100644
index 0000000..8723919
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ComposeNode
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetCoreEntity
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetMeasurePolicy
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetModifier
+import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetName
+import androidx.xr.compose.subspace.node.SubspaceNodeApplier
+
+/**
+ * [SubspaceLayout] is the main core component for layout for "leaf" nodes. It can be used to
+ * measure and position zero children.
+ *
+ * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by
+ * the [measurePolicy] instance. See [MeasurePolicy] for more details.
+ *
+ * @param modifier SubspaceModifier to apply during layout.
+ * @param name a name for the ComposeSubspaceNode. This can be useful for debugging.
+ * @param measurePolicy a policy defining the measurement and positioning of the layout.
+ */
+@Suppress("NOTHING_TO_INLINE")
+@SubspaceComposable
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceLayout(
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultSubspaceLayoutName(),
+    measurePolicy: MeasurePolicy,
+) {
+    ComposeNode<ComposeSubspaceNode, SubspaceNodeApplier>(
+        factory = ComposeSubspaceNode.Constructor,
+        update = {
+            set(measurePolicy, SetMeasurePolicy)
+            set(modifier, SetModifier)
+            set(name, SetName)
+        },
+    )
+}
+
+/**
+ * [SubspaceLayout] is the main core component for layout. It can be used to measure and position
+ * zero or more layout children.
+ *
+ * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by
+ * the [measurePolicy] instance. See [MeasurePolicy] for more details.
+ *
+ * @param modifier SubspaceModifier to apply during layout
+ * @param content the children composable to be laid out.
+ * @param name a name for the ComposeSubspaceNode. This can be useful for debugging.
+ * @param measurePolicy a policy defining the measurement and positioning of the layout.
+ */
+@Suppress("ComposableLambdaParameterPosition")
+@SubspaceComposable
+@Composable
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceLayout(
+    content: @Composable @SubspaceComposable () -> Unit,
+    modifier: SubspaceModifier = SubspaceModifier,
+    name: String = defaultSubspaceLayoutName(),
+    measurePolicy: MeasurePolicy,
+) {
+    ComposeNode<ComposeSubspaceNode, SubspaceNodeApplier>(
+        factory = ComposeSubspaceNode.Constructor,
+        update = {
+            set(measurePolicy, SetMeasurePolicy)
+            set(modifier, SetModifier)
+            set(name, SetName)
+        },
+        content = content,
+    )
+}
+
+/**
+ * [SubspaceLayout] is the main core component for layout for "leaf" nodes. It can be used to
+ * measure and position zero children.
+ *
+ * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by
+ * the [measurePolicy] instance. See [MeasurePolicy] for more details.
+ *
+ * @param modifier SubspaceModifier to apply during layout.
+ * @param coreEntity SceneCore Entity being placed in this layout. This parameter is generally not
+ *   needed for most use cases and should be avoided unless you have specific requirements to manage
+ *   entities outside the Compose framework. If provided, it will associate the [SubspaceLayout]
+ *   with the given SceneCore Entity.
+ * @param name a name for the ComposeSubspaceNode. This can be useful for debugging.
+ * @param measurePolicy a policy defining the measurement and positioning of the layout.
+ */
+@Suppress("NOTHING_TO_INLINE")
+@SubspaceComposable
+@Composable
+internal fun SubspaceLayout(
+    modifier: SubspaceModifier = SubspaceModifier,
+    coreEntity: CoreEntity? = null,
+    name: String = defaultSubspaceLayoutName(),
+    measurePolicy: MeasurePolicy,
+) {
+    ComposeNode<ComposeSubspaceNode, SubspaceNodeApplier>(
+        factory = ComposeSubspaceNode.Constructor,
+        update = {
+            set(measurePolicy, SetMeasurePolicy)
+            set(coreEntity, SetCoreEntity)
+            // Execute SetModifier after SetCoreEntity, it depends on CoreEntity.
+            set(modifier, SetModifier)
+            set(name, SetName)
+        },
+    )
+}
+
+/**
+ * [SubspaceLayout] is the main core component for layout. It can be used to measure and position
+ * zero or more layout children.
+ *
+ * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by
+ * the [measurePolicy] instance. See [MeasurePolicy] for more details.
+ *
+ * @param modifier SubspaceModifier to apply during layout
+ * @param coreEntity SceneCore Entity being placed in this layout. This parameter is generally not
+ *   needed for most use cases and should be avoided unless you have specific requirements to manage
+ *   entities outside the Compose framework. If provided, it will associate the [SubspaceLayout]
+ *   with the given SceneCore Entity.
+ * @param content the children composable to be laid out.
+ * @param name a name for the ComposeSubspaceNode. This can be useful for debugging.
+ * @param measurePolicy a policy defining the measurement and positioning of the layout.
+ */
+@Suppress("ComposableLambdaParameterPosition")
+@SubspaceComposable
+@Composable
+internal fun SubspaceLayout(
+    content: @Composable @SubspaceComposable () -> Unit,
+    modifier: SubspaceModifier = SubspaceModifier,
+    coreEntity: CoreEntity? = null,
+    name: String = defaultSubspaceLayoutName(),
+    measurePolicy: MeasurePolicy,
+) {
+    ComposeNode<ComposeSubspaceNode, SubspaceNodeApplier>(
+        factory = ComposeSubspaceNode.Constructor,
+        update = {
+            set(measurePolicy, SetMeasurePolicy)
+            set(coreEntity, SetCoreEntity)
+            // Execute SetModifier after SetCoreEntity, it depends on CoreEntity.
+            set(modifier, SetModifier)
+            set(name, SetName)
+        },
+        content = content,
+    )
+}
+
+private var subspaceLayoutNamePart: Int = 0
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun defaultSubspaceLayoutName(): String {
+    return "SubspaceLayoutNode-${subspaceLayoutNamePart++}"
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayoutCoordinates.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayoutCoordinates.kt
new file mode 100644
index 0000000..098adff
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayoutCoordinates.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * A holder of the measured bounds.
+ *
+ * Based on [androidx.compose.ui.layout.LayoutCoordinates].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SubspaceLayoutCoordinates {
+    /** The pose of this layout in the local coordinates space, with translation in pixels. */
+    public val pose: Pose
+
+    /**
+     * The pose of this layout relative to the root entity of the Compose hierarchy, with
+     * translation in pixels.
+     */
+    public val poseInRoot: Pose
+
+    /**
+     * The pose of this layout relative to its parent entity in the Compose hierarchy, with
+     * translation in pixels.
+     */
+    public val poseInParentEntity: Pose
+
+    /** The position of the pose. */
+    @Deprecated("Use pose.position instead", ReplaceWith("pose.position"))
+    public val position: Vector3
+        get() = pose.translation
+
+    /**
+     * The position of this layout relative to the root entity of the Compose hierarchy.
+     *
+     * For testing only.
+     */
+    @Deprecated("Use poseInRoot.position instead", ReplaceWith("poseInRoot.position"))
+    public val positionInRoot: Vector3
+        get() = poseInRoot.translation
+
+    /** The position of this layout relative to its parent entity in the Compose hierarchy. */
+    @Deprecated(
+        "Use poseInParentEntity.position instead",
+        ReplaceWith("poseInParentEntity.position")
+    )
+    public val positionInParentEntity: Vector3
+        get() = poseInParentEntity.translation
+
+    /** The rotation of the pose. */
+    @Deprecated("Use pose.rotation instead", ReplaceWith("pose.rotation"))
+    public val rotation: Quaternion
+        get() = pose.rotation
+
+    /**
+     * The rotation of this layout relative to the root entity of the Compose hierarchy.
+     *
+     * For testing only.
+     */
+    @Deprecated("Use poseInRoot.rotation instead", ReplaceWith("poseInRoot.rotation"))
+    public val rotationInRoot: Quaternion
+        get() = poseInRoot.rotation
+
+    /** The rotation of this layout relative to its parent entity in the Compose hierarchy. */
+    @Deprecated(
+        "Use poseInParentEntity.rotation instead",
+        ReplaceWith("poseInParentEntity.rotation")
+    )
+    public val rotationInParentEntity: Quaternion
+        get() = poseInParentEntity.rotation
+
+    /**
+     * The size of this layout in the local coordinates space.
+     *
+     * This is also useful for providing the size of the node to the [OnGloballyPositionedModifier].
+     */
+    public val size: IntVolumeSize
+}
+
+/** Returns information on pose, position and size. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceLayoutCoordinates.toDebugString(): String = buildString {
+    appendLine("pose: $pose")
+    appendLine("poseInParentEntity: $poseInParentEntity")
+    appendLine("size: $size")
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceModifier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceModifier.kt
new file mode 100644
index 0000000..e82ec66
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceModifier.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNode
+import androidx.xr.compose.subspace.node.SubspaceLayoutModifierNodeCoordinator
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+
+/**
+ * An ordered, immutable collection of [subspace modifier elements][SubspaceModifierElement] that
+ * decorate or add behavior to Subspace Compose elements.
+ *
+ * Based on [androidx.compose.ui.Modifier]
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SubspaceModifier {
+
+    /**
+     * Accumulates a value starting with [initial] and applying [operation] to the current value and
+     * each SubspaceModifierElement from outside in.
+     */
+    public fun <R> foldIn(
+        initial: R,
+        operation: (R, SubspaceModifierElement<SubspaceModifier.Node>) -> R,
+    ): R = initial
+
+    /**
+     * Accumulates a value starting with [initial] and applying [operation] to the current value and
+     * each SubspaceModifierElement from inside out.
+     */
+    public fun <R> foldOut(
+        initial: R,
+        operation: (SubspaceModifierElement<SubspaceModifier.Node>, R) -> R,
+    ): R = initial
+
+    /**
+     * Returns `true` if [predicate] returns true for any [SubspaceModifierElement] in this
+     * [SubspaceModifier].
+     */
+    public fun any(
+        predicate: (SubspaceModifierElement<SubspaceModifier.Node>) -> Boolean
+    ): Boolean = false
+
+    /**
+     * Returns `true` if [predicate] returns true for all [SubspaceModifierElement]s in this
+     * [SubspaceModifier] or if this [SubspaceModifier] contains no [Element]s.
+     */
+    public fun all(
+        predicate: (SubspaceModifierElement<SubspaceModifier.Node>) -> Boolean
+    ): Boolean = true
+
+    /**
+     * Concatenates this modifier with another.
+     *
+     * Returns a [SubspaceModifier] representing this modifier followed by [other] in sequence.
+     */
+    public infix fun then(other: SubspaceModifier): SubspaceModifier =
+        if (other === SubspaceModifier) this else CombinedSubspaceModifier(this, other)
+
+    /**
+     * The longer-lived object that is created for each [SubspaceModifierElement] applied to a
+     * [SubspaceLayout]
+     */
+    public abstract class Node {
+        internal var parent: Node? = null
+        internal var child: Node? = null
+        internal val coordinator: SubspaceLayoutModifierNodeCoordinator? = run {
+            if (this is SubspaceLayoutModifierNode) {
+                SubspaceLayoutModifierNodeCoordinator(this)
+            } else {
+                null
+            }
+        }
+    }
+
+    /**
+     * The companion object `SubspaceModifier` is the empty, default, or starter [SubspaceModifier]
+     * that contains no [SubspaceModifierElements][SubspaceModifierElement].
+     */
+    public companion object : SubspaceModifier {
+
+        public infix fun then(
+            other: SubspaceModifierElement<SubspaceModifier.Node>
+        ): SubspaceModifier = other
+
+        override fun toString(): String = "SubspaceModifier"
+    }
+}
+
+/**
+ * A node in a [SubspaceModifier] chain. A CombinedSubspaceModifier always contains at least two
+ * elements; a SubspaceModifier [outer] that wraps around the SubspaceModifier [inner].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class CombinedSubspaceModifier(
+    internal val outer: SubspaceModifier,
+    internal val inner: SubspaceModifier,
+) : SubspaceModifier {
+    override fun <R> foldIn(
+        initial: R,
+        operation: (R, SubspaceModifierElement<SubspaceModifier.Node>) -> R,
+    ): R = inner.foldIn(outer.foldIn(initial, operation), operation)
+
+    override fun <R> foldOut(
+        initial: R,
+        operation: (SubspaceModifierElement<SubspaceModifier.Node>, R) -> R,
+    ): R = outer.foldOut(inner.foldOut(initial, operation), operation)
+
+    override fun any(
+        predicate: (SubspaceModifierElement<SubspaceModifier.Node>) -> Boolean
+    ): Boolean = outer.any(predicate) || inner.any(predicate)
+
+    override fun all(
+        predicate: (SubspaceModifierElement<SubspaceModifier.Node>) -> Boolean
+    ): Boolean = outer.all(predicate) && inner.all(predicate)
+
+    override fun equals(other: Any?): Boolean =
+        other is CombinedSubspaceModifier && outer == other.outer && inner == other.inner
+
+    override fun hashCode(): Int = outer.hashCode() + 31 * inner.hashCode()
+
+    override fun toString(): String =
+        "[" +
+            foldIn("") { acc, element ->
+                if (acc.isEmpty()) element.toString() else "$acc, $element"
+            } +
+            "]"
+}
+
+/**
+ * Generates a lazy sequence that walks up the node tree to the root.
+ *
+ * If this node is the root, an empty sequence is returned.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.Node.traverseAncestors(): Sequence<SubspaceModifier.Node> {
+    return generateSequence(seed = parent) { it.parent }
+}
+
+/** Generates a sequence with self and elements up the node tree to the root. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.Node.traverseSelfThenAncestors(): Sequence<SubspaceModifier.Node> =
+    sequenceOf(this) + traverseAncestors()
+
+/**
+ * Generates a lazy sequence that walks down the node tree.
+ *
+ * If this node is a leaf node, an empty sequence is returned.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.Node.traverseDescendants(): Sequence<SubspaceModifier.Node> {
+    return generateSequence(seed = child) { it.child }
+}
+
+/** Generates a sequence with self and elements down the node tree. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.Node.traverseSelfThenDescendants(): Sequence<SubspaceModifier.Node> =
+    sequenceOf(this) + traverseDescendants()
+
+/** Returns the first element of type [T] in the sequence, or `null` if none match. */
+internal inline fun <reified T> Sequence<*>.findInstance(): T? = firstOrNull { it is T } as T?
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceRootMeasurePolicy.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceRootMeasurePolicy.kt
new file mode 100644
index 0000000..016915a
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceRootMeasurePolicy.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * MeasurePolicy applied at the root of the compose tree.
+ *
+ * Based on [androidx.compose.ui.layout.RootMeasurePolicy].
+ */
+internal class SubspaceRootMeasurePolicy() : MeasurePolicy {
+    override fun MeasureScope.measure(
+        measurables: List<Measurable>,
+        constraints: VolumeConstraints,
+    ): MeasureResult {
+        return when {
+            measurables.isEmpty() -> {
+                layout(constraints.minWidth, constraints.minHeight, constraints.minDepth) {}
+            }
+            measurables.size == 1 -> {
+                val placeable = measurables[0].measure(constraints)
+                layout(placeable.measuredWidth, placeable.measuredHeight, placeable.measuredDepth) {
+                    placeable.place(Pose(Vector3.Zero, Quaternion.Identity))
+                }
+            }
+            else -> {
+                val placeables = measurables.fastMap { it.measure(constraints) }
+                var maxWidth = 0
+                var maxHeight = 0
+                var maxDepth = 0
+                placeables.fastForEach { placeable ->
+                    maxWidth = maxOf(placeable.measuredWidth, maxWidth)
+                    maxHeight = maxOf(placeable.measuredHeight, maxHeight)
+                    maxDepth = maxOf(placeable.measuredDepth, maxDepth)
+                }
+                layout(maxWidth, maxHeight, maxDepth) {
+                    placeables.fastForEach { placeable ->
+                        placeable.place(Pose(Vector3.Zero, Quaternion.Identity))
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/TestTag.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/TestTag.kt
new file mode 100644
index 0000000..b4664fe
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/TestTag.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+import androidx.compose.ui.semantics.testTag
+import androidx.xr.compose.subspace.node.SubspaceModifierElement
+import androidx.xr.compose.subspace.node.SubspaceSemanticsModifierNode
+
+/**
+ * Applies a tag to allow modified element to be found in tests.
+ *
+ * This is a convenience method for a [semantics] that sets [SemanticsPropertyReceiver.testTag].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun SubspaceModifier.testTag(tag: String): SubspaceModifier = this then TestTagElement(tag)
+
+private class TestTagElement(private val tag: String) : SubspaceModifierElement<TestTagNode>() {
+    override fun create(): TestTagNode {
+        return TestTagNode(tag)
+    }
+
+    override fun update(node: TestTagNode) {
+        node.tag = tag
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is TestTagElement) return false
+        return tag == other.tag
+    }
+
+    override fun hashCode(): Int {
+        return tag.hashCode()
+    }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class TestTagNode(public var tag: String) :
+    SubspaceModifier.Node(), SubspaceSemanticsModifierNode {
+    override fun SemanticsPropertyReceiver.applySemantics() {
+        testTag = tag
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/ComposeSubspaceNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/ComposeSubspaceNode.kt
new file mode 100644
index 0000000..e3bf376
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/ComposeSubspaceNode.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.xr.compose.subspace.layout.CoreEntity
+import androidx.xr.compose.subspace.layout.MeasurePolicy
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+
+/**
+ * Represents a Composable Subspace node in the Compose hierarchy.
+ *
+ * This interface is inspired by [androidx.compose.ui.node.ComposeUiNode].
+ */
+internal interface ComposeSubspaceNode {
+
+    /** The [MeasurePolicy] used to define the measure and layout behavior of this node. */
+    public var measurePolicy: MeasurePolicy
+
+    /** The [SubspaceModifier] applied to this node. */
+    public var modifier: SubspaceModifier
+
+    /** The optional [CoreEntity] associated with this node. */
+    public var coreEntity: CoreEntity?
+
+    /** An optional name for this node, useful for debugging and identification purposes. */
+    public var name: String?
+
+    public companion object {
+        /**
+         * Constructor function for creating a new [ComposeSubspaceNode].
+         *
+         * @return an instance of a [ComposeSubspaceNode].
+         */
+        public val Constructor: () -> ComposeSubspaceNode = SubspaceLayoutNode.Constructor
+
+        /**
+         * Sets the [MeasurePolicy] for the given [ComposeSubspaceNode].
+         *
+         * @param measurePolicy the [MeasurePolicy] to be applied.
+         */
+        public val SetMeasurePolicy: ComposeSubspaceNode.(MeasurePolicy) -> Unit = {
+            this.measurePolicy = it
+        }
+
+        /**
+         * Sets the [CoreEntity] for the given [ComposeSubspaceNode].
+         *
+         * @param coreEntity the [CoreEntity] to be associated, or null.
+         */
+        public val SetCoreEntity: ComposeSubspaceNode.(CoreEntity?) -> Unit = {
+            this.coreEntity = it
+        }
+
+        /**
+         * Sets the [SubspaceModifier] for the given [ComposeSubspaceNode].
+         *
+         * Note: [SetCoreEntity] should be called before.
+         *
+         * @param modifier the [SubspaceModifier] to be applied.
+         */
+        public val SetModifier: ComposeSubspaceNode.(SubspaceModifier) -> Unit = {
+            this.modifier = it
+        }
+
+        /**
+         * Sets the name for the given [ComposeSubspaceNode].
+         *
+         * @param name the name to be assigned.
+         */
+        public val SetName: ComposeSubspaceNode.(String) -> Unit = { this.name = it }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNode.kt
new file mode 100644
index 0000000..9e34c00
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNode.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.unit.VolumeConstraints
+
+/**
+ * A specialized [SubspaceModifier.Node] responsible for modifying the measurement and layout
+ * behavior of its wrapped content within the Subspace environment.
+ *
+ * Based on [androidx.compose.ui.node.LayoutModifierNode].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SubspaceLayoutModifierNode {
+
+    /**
+     * Defines the measurement and layout of the [Measurable] within the given [MeasureScope].
+     *
+     * The measurable is subject to the specified [VolumeConstraints].
+     *
+     * @param measurable the content to be measured.
+     * @param constraints the constraints within which the measurement should occur.
+     * @return a [MeasureResult] encapsulating the size and alignment lines of the measured layout.
+     */
+    public fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: VolumeConstraints,
+    ): MeasureResult
+}
+
+/**
+ * Returns the [SubspaceLayoutModifierNodeCoordinator] associated with this
+ * [SubspaceLayoutModifierNode].
+ *
+ * This is used to traverse the modifier node tree to find the correct [SubspaceLayoutCoordinates]
+ * for a given [SubspaceLayoutModifierNode].
+ */
+internal val SubspaceLayoutModifierNode.coordinator: SubspaceLayoutModifierNodeCoordinator
+    get() {
+        check(this is SubspaceModifier.Node && this.coordinator != null) {
+            "SubspaceLayoutModifierNode must be a SubspaceModifier.Node and have a non-null coordinator."
+        }
+        return (this as SubspaceModifier.Node).coordinator!!
+    }
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt
new file mode 100644
index 0000000..b017ab8
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable
+import androidx.xr.compose.subspace.layout.Placeable
+import androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+
+/**
+ * A [Measurable] and [Placeable] object that is used to measure and lay out the children of a
+ * [SubspaceLayoutModifierNode].
+ *
+ * See [androidx.compose.ui.node.NodeCoordinator]
+ *
+ * The [SubspaceLayoutModifierNodeCoordinator] is mapped 1:1 with a [SubspaceLayoutModifierNode] so
+ * we don't need to maintain a separate node coordinator hierarchy. Instead, we can use the
+ * hierarchy already present in [SubspaceModifier.Node] types.
+ */
+internal class SubspaceLayoutModifierNodeCoordinator(
+    private val layoutModifierNode: SubspaceLayoutModifierNode
+) : SubspaceLayoutCoordinates, Measurable {
+
+    private val baseNode: SubspaceModifier.Node
+        get() = layoutModifierNode as SubspaceModifier.Node
+
+    internal var layoutNode: SubspaceLayoutNode? = null
+
+    internal val parent: SubspaceLayoutModifierNodeCoordinator?
+        get() =
+            generateSequence(baseNode.parent) { it.parent }.firstNotNullOfOrNull { it.coordinator }
+
+    internal val child: SubspaceLayoutModifierNodeCoordinator?
+        get() =
+            generateSequence(baseNode.child) { it.child }.firstNotNullOfOrNull { it.coordinator }
+
+    /** The pose of this layout in the local coordinates space. */
+    override val pose: Pose
+        get() = layoutPose ?: Pose.Identity
+
+    /**
+     * The pose of this layout modifier node relative to the root entity of the Compose hierarchy.
+     */
+    override val poseInRoot: Pose
+        get() =
+            coordinatesInRoot?.poseInRoot?.let {
+                pose.translate(it.translation).rotate(it.rotation)
+            } ?: pose
+
+    /**
+     * The pose of this layout modifier node relative to its parent entity in the Compose hierarchy.
+     */
+    override val poseInParentEntity: Pose
+        get() =
+            coordinatesInParentEntity?.poseInParentEntity?.let {
+                pose.translate(it.translation).rotate(it.rotation)
+            } ?: pose
+
+    /**
+     * The layout coordinates of the parent [SubspaceLayoutNode] up to the root of the hierarchy
+     * including application from any [SubspaceLayoutModifierNode] instances applied to this node.
+     *
+     * This applies the layout changes of all [SubspaceLayoutModifierNode] instances in the modifier
+     * chain and then [layoutNode]'s parent or just [layoutNode]'s parent and this modifier if no
+     * other [SubspaceLayoutModifierNode] is present.
+     */
+    private val coordinatesInRoot: SubspaceLayoutCoordinates?
+        get() = parent ?: layoutNode?.measurableLayout?.parentCoordinatesInRoot
+
+    /**
+     * The layout coordinates up to the nearest parent [CoreEntity] including mutations from any
+     * [SubspaceLayoutModifierNode] instances applied to this node.
+     *
+     * This applies the layout changes of all [SubspaceLayoutModifierNode] instances in the modifier
+     * chain and then [layoutNode]'s parent or just [layoutNode]'s parent and this modifier if no
+     * other [SubspaceLayoutModifierNode] is present.
+     */
+    private val coordinatesInParentEntity: SubspaceLayoutCoordinates?
+        get() = parent ?: layoutNode?.measurableLayout?.parentCoordinatesInParentEntity
+
+    /** The size of this layout in the local coordinates space. */
+    override val size: IntVolumeSize
+        get() =
+            IntVolumeSize(
+                width = placeable.measuredWidth,
+                height = placeable.measuredHeight,
+                depth = placeable.measuredDepth,
+            )
+
+    private var measureResult: MeasureResult? = null
+    private var layoutPose: Pose? = null
+
+    /**
+     * The [Placeable] representing the placed content of this modifier. It handles placing child
+     * content based on the layout pose.
+     */
+    public var placeable: Placeable =
+        object : Placeable() {
+            public override fun placeAt(pose: Pose) {
+                layoutPose = pose
+                measureResult?.placeChildren(
+                    object : PlacementScope() {
+                        public override val coordinates = this@SubspaceLayoutModifierNodeCoordinator
+                    }
+                )
+            }
+        }
+
+    /**
+     * Measures the wrapped content within the given [constraints].
+     *
+     * @param constraints the constraints to apply during measurement.
+     * @return the [Placeable] representing the measured child layout that can be positioned by its
+     *   parent layout.
+     */
+    override fun measure(constraints: VolumeConstraints): Placeable {
+        with(layoutModifierNode) {
+            val measurable: Measurable = child ?: layoutNode!!.measurableLayout
+            val measureResult: MeasureResult =
+                object : MeasureScope {}.measure(measurable, constraints).also {
+                    [email protected] = it
+                }
+            return placeable.apply {
+                measuredWidth = measureResult.width
+                measuredHeight = measureResult.height
+                measuredDepth = measureResult.depth
+            }
+        }
+    }
+
+    /**
+     * Adjusts layout of the wrapped content with a new [ParentLayoutParamsAdjustable].
+     *
+     * @param params the parameters to be adjusted.
+     */
+    override fun adjustParams(params: ParentLayoutParamsAdjustable) {
+        layoutNode?.measurableLayout?.adjustParams(params)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt
new file mode 100644
index 0000000..ebfdb55
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt
@@ -0,0 +1,423 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.xr.compose.subspace.layout.CoreEntity
+import androidx.xr.compose.subspace.layout.Measurable
+import androidx.xr.compose.subspace.layout.MeasurePolicy
+import androidx.xr.compose.subspace.layout.MeasureResult
+import androidx.xr.compose.subspace.layout.MeasureScope
+import androidx.xr.compose.subspace.layout.OnGloballyPositionedNode
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable
+import androidx.xr.compose.subspace.layout.ParentLayoutParamsModifier
+import androidx.xr.compose.subspace.layout.Placeable
+import androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.SubspaceRootMeasurePolicy
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import java.util.concurrent.atomic.AtomicInteger
+
+private var lastIdentifier = AtomicInteger(0)
+
+internal fun generateSemanticsId() = lastIdentifier.incrementAndGet()
+
+/**
+ * An element in the Subspace layout hierarchy (spatial scene graph), built with Compose UI for
+ * subspace.
+ *
+ * This class is based on [androidx.compose.ui.node.LayoutNode].
+ *
+ * TODO(b/330925589): Write unit tests.
+ * TODO(b/333904965): Make this class 'internal'.
+ */
+internal class SubspaceLayoutNode : ComposeSubspaceNode {
+    /**
+     * The children of this [SubspaceLayoutNode], controlled by [insertAt], [move], and [removeAt].
+     */
+    internal val children: MutableList<SubspaceLayoutNode> = mutableListOf()
+
+    /** The parent node in the [SubspaceLayoutNode] hierarchy. */
+    internal var parent: SubspaceLayoutNode? = null
+
+    /** Unique ID used by semantics libraries. */
+    public val semanticsId: Int = generateSemanticsId()
+
+    /** Instance of [MeasurableLayout] to aid with measure/layout phases. */
+    public val measurableLayout: MeasurableLayout = MeasurableLayout()
+
+    /** The element system [SubspaceOwner]. This value is `null` until [attach] is called */
+    internal var owner: SubspaceOwner? = null
+        private set
+
+    internal val nodes: SubspaceModifierNodeChain = SubspaceModifierNodeChain(this)
+
+    override var measurePolicy: MeasurePolicy = ErrorMeasurePolicy
+
+    override var modifier: SubspaceModifier = SubspaceModifier
+        set(value) {
+            field = value
+            nodes.updateFrom(value)
+            updateCoreEntity()
+        }
+
+    override var coreEntity: CoreEntity? = null
+        set(value) {
+            check(field == null) { "overwriting non-null CoreEntity is not supported" }
+            field = value
+            if (value != null) {
+                value.layout = measurableLayout
+            }
+        }
+
+    override var name: String? = null
+
+    private fun updateCoreEntity() {
+        coreEntity?.applyModifiers(nodes.getAll())
+    }
+
+    /**
+     * This function sets up CoreEntity parent/child relationships that reflect the parent/child
+     * relationships of the corresponding SubspaceLayoutNodes. This should be called any time the
+     * `parent` or `coreEntity` fields are updated.
+     */
+    private fun syncCoreEntityHierarchy() {
+        coreEntity?.parent = findCoreEntityParent(this)
+    }
+
+    /** Inserts a child [SubspaceLayoutNode] at the given [index]. */
+    internal fun insertAt(index: Int, instance: SubspaceLayoutNode) {
+        check(instance.parent == null) {
+            "Cannot insert $instance because it already has a parent." +
+                " This tree: " +
+                debugTreeToString() +
+                " Parent tree: " +
+                parent?.debugTreeToString()
+        }
+        check(instance.owner == null) {
+            "Cannot insert $instance because it already has an owner." +
+                " This tree: " +
+                debugTreeToString() +
+                " Other tree: " +
+                instance.debugTreeToString()
+        }
+
+        instance.parent = this
+        children.add(index, instance)
+
+        owner?.let { instance.attach(it) }
+    }
+
+    /**
+     * Moves [count] elements starting at index [from] to index [to].
+     *
+     * The [to] index is related to the position before the change, so, for example, to move an
+     * element at position 1 to after the element at position 2, [from] should be `1` and [to]
+     * should be `3`. If the elements were [SubspaceLayoutNode] instances, A B C D E, calling
+     * `move(1, 3, 1)` would result in the nodes being reordered to A C B D E.
+     */
+    internal fun move(from: Int, to: Int, count: Int) {
+        if (from == to) {
+            return // nothing to do
+        }
+
+        for (i in 0 until count) {
+            // if "from" is after "to," the from index moves because we're inserting before it
+            val fromIndex = if (from > to) from + i else from
+            val toIndex = if (from > to) to + i else to + count - 2
+            val child = children.removeAt(fromIndex)
+
+            children.add(toIndex, child)
+        }
+    }
+
+    /** Removes one or more children, starting at [index]. */
+    internal fun removeAt(index: Int, count: Int) {
+        require(count >= 0) { "count ($count) must be greater than 0." }
+
+        for (i in index + count - 1 downTo index) {
+            onChildRemoved(children[i])
+        }
+
+        children.removeAll(children.subList(index, index + count))
+    }
+
+    /** Removes all children nodes. */
+    internal fun removeAll() {
+        children.reversed().forEach { child -> onChildRemoved(child) }
+
+        children.clear()
+    }
+
+    /** Called when the [child] node is removed from this [SubspaceLayoutNode] hierarchy. */
+    private fun onChildRemoved(child: SubspaceLayoutNode) {
+        owner?.let { child.detach() }
+        child.parent = null
+    }
+
+    /**
+     * Sets the [SubspaceOwner] of this node.
+     *
+     * This SubspaceLayoutNode must not already be attached and [subspaceOwner] must match the
+     * [parent]'s [subspaceOwner].
+     */
+    internal fun attach(subspaceOwner: SubspaceOwner) {
+        check(this.owner == null) {
+            "Cannot attach $this as it already is attached. Tree: " + debugTreeToString()
+        }
+        check(parent == null || parent?.owner == subspaceOwner) {
+            "Attaching to a different owner($subspaceOwner) than the parent's owner" +
+                "(${parent?.owner})." +
+                " This tree: " +
+                debugTreeToString() +
+                " Parent tree: " +
+                parent?.debugTreeToString()
+        }
+
+        this.owner = subspaceOwner
+
+        subspaceOwner.onAttach(this)
+        syncCoreEntityHierarchy()
+
+        children.forEach { child -> child.attach(subspaceOwner) }
+    }
+
+    /**
+     * Detaches this node from the [owner].
+     *
+     * The [owner] must not be `null` when this method is called.
+     *
+     * This will also [detach] all children. After executing, the [owner] will be `null`.
+     */
+    internal fun detach() {
+        val owner = owner
+
+        checkNotNull(owner) {
+            "Cannot detach node that is already detached!  Tree: " + parent?.debugTreeToString()
+        }
+
+        children.forEach { child -> child.detach() }
+
+        owner.onDetach(this)
+        this.owner = null
+    }
+
+    override fun toString(): String {
+        return name ?: super.toString()
+    }
+
+    /** Call this method to see a dump of the SpatialLayoutNode tree structure. */
+    @Suppress("unused")
+    internal fun debugTreeToString(depth: Int = 0): String {
+        val tree = StringBuilder()
+        val depthString = "  ".repeat(depth)
+        tree.append("$depthString|-${toString()}\n")
+
+        var currentNode: SubspaceModifier.Node? = nodes.head
+        while (currentNode != null && currentNode != nodes.tail) {
+            tree.append("$depthString  *-$currentNode\n")
+            currentNode = currentNode.child
+        }
+
+        children.forEach { child -> tree.append(child.debugTreeToString(depth + 1)) }
+
+        var treeString = tree.toString()
+        if (depth == 0) {
+            // Delete trailing newline
+            treeString = treeString.substring(0, treeString.length - 1)
+        }
+
+        return treeString
+    }
+
+    /** Call this method to see a dump of the Jetpack XR node hierarchy. */
+    @Suppress("unused")
+    internal fun debugEntityTreeToString(depth: Int = 0): String {
+        val tree = StringBuilder()
+        val depthString = "  ".repeat(depth)
+        var nextDepth = depth
+        if (coreEntity != null) {
+            tree.append(
+                "$depthString|-${coreEntity?.entity} -> ${findCoreEntityParent(this)?.entity}\n"
+            )
+            nextDepth++
+        }
+
+        children.forEach { child -> tree.append(child.debugEntityTreeToString(nextDepth)) }
+
+        var treeString = tree.toString()
+        if (depth == 0 && treeString.isNotEmpty()) {
+            // Delete trailing newline
+            treeString = treeString.substring(0, treeString.length - 1)
+        }
+
+        return treeString
+    }
+
+    /**
+     * A [Measurable] and [Placeable] object that is used to measure and lay out the children of
+     * this node.
+     *
+     * See [androidx.compose.ui.node.NodeCoordinator]
+     */
+    public inner class MeasurableLayout : Measurable, SubspaceLayoutCoordinates, Placeable() {
+        private var layoutPose: Pose? = null
+        private var measureResult: MeasureResult? = null
+
+        /**
+         * The tail node of [SubspaceModifierNodeChain].
+         *
+         * This node is used to mark the end of the modifier chain.
+         */
+        public val tail: SubspaceModifier.Node = TailModifierNode()
+
+        override fun measure(constraints: VolumeConstraints): Placeable =
+            nodes.measureChain(constraints, ::measureJustThis)
+
+        override fun adjustParams(params: ParentLayoutParamsAdjustable) {
+            nodes.getAll<ParentLayoutParamsModifier>().forEach { it.adjustParams(params) }
+        }
+
+        private fun measureJustThis(constraints: VolumeConstraints): Placeable {
+            measureResult =
+                with(measurePolicy) {
+                    object : MeasureScope {}.measure(
+                        [email protected] { it.measurableLayout }.toList(),
+                        constraints,
+                    )
+                }
+
+            measuredWidth = measureResult!!.width
+            measuredHeight = measureResult!!.height
+            measuredDepth = measureResult!!.depth
+
+            coreEntity?.size = IntVolumeSize(measuredWidth, measuredHeight, measuredDepth)
+
+            return this
+        }
+
+        /**
+         * Places the children of this node at the given pose.
+         *
+         * @param pose The pose to place the children at, with translation in pixels.
+         */
+        public override fun placeAt(pose: Pose) {
+            layoutPose = pose
+
+            coreEntity?.applyLayoutChanges()
+
+            measureResult?.placeChildren(
+                object : PlacementScope() {
+                    override val coordinates = this@MeasurableLayout
+                }
+            )
+
+            // Call OnGloballyPositioned callbacks after the node and its children are placed.
+            nodes.getAll<OnGloballyPositionedNode>().forEach { it.callback(this) }
+        }
+
+        override val pose: Pose
+            get() = layoutPose ?: Pose.Identity
+
+        override val poseInRoot: Pose
+            get() =
+                coordinatesInRoot?.poseInRoot?.let {
+                    pose.translate(it.translation).rotate(it.rotation)
+                } ?: pose
+
+        override val poseInParentEntity: Pose
+            get() =
+                coordinatesInParentEntity?.poseInParentEntity?.let {
+                    pose.translate(it.translation).rotate(it.rotation)
+                } ?: pose
+
+        /**
+         * The layout coordinates of the parent [SubspaceLayoutNode] up to the root of the hierarchy
+         * including application from any [SubspaceLayoutModifierNode] instances applied to this
+         * node.
+         *
+         * This applies the layout changes of all [SubspaceLayoutModifierNode] instances in the
+         * modifier chain and then [parentCoordinatesInRoot] or just [parentCoordinatesInRoot] if no
+         * [SubspaceLayoutModifierNode] is present.
+         */
+        private val coordinatesInRoot: SubspaceLayoutCoordinates?
+            get() =
+                coreEntity
+                    ?: nodes.getLast<SubspaceLayoutModifierNode>()?.coordinator
+                    ?: parentCoordinatesInRoot
+
+        /** Traverse the parent hierarchy up to the root. */
+        internal val parentCoordinatesInRoot: SubspaceLayoutCoordinates?
+            get() = parent?.measurableLayout
+
+        /**
+         * The layout coordinates up to the nearest parent [CoreEntity] including mutations from any
+         * [SubspaceLayoutModifierNode] instances applied to this node.
+         *
+         * This applies the layout changes of all [SubspaceLayoutModifierNode] instances in the
+         * modifier chain and then [parentCoordinatesInParentEntity] or just
+         * [parentCoordinatesInParentEntity] if no [SubspaceLayoutModifierNode] is present.
+         */
+        private val coordinatesInParentEntity: SubspaceLayoutCoordinates?
+            get() =
+                coreEntity
+                    ?: nodes.getLast<SubspaceLayoutModifierNode>()?.coordinator
+                    ?: parentCoordinatesInParentEntity
+
+        /** Traverse up the parent hierarchy until we reach a node with an entity. */
+        internal val parentCoordinatesInParentEntity: SubspaceLayoutCoordinates?
+            get() = if (parent?.coreEntity == null) parent?.measurableLayout else null
+
+        override val size: IntVolumeSize
+            get() {
+                return coreEntity?.size
+                    ?: IntVolumeSize(measuredWidth, measuredHeight, measuredDepth)
+            }
+
+        override fun toString(): String {
+            return [email protected]()
+        }
+    }
+
+    /** Companion object for [SubspaceLayoutNode]. */
+    public companion object {
+        private val ErrorMeasurePolicy: MeasurePolicy = MeasurePolicy { _, _ ->
+            error("Undefined measure and it is required")
+        }
+
+        /**
+         * A [MeasurePolicy] that is used for the root node of the Subspace layout hierarchy.
+         *
+         * Note: Root node itself has no size outside its children.
+         */
+        public val RootMeasurePolicy: MeasurePolicy = SubspaceRootMeasurePolicy()
+
+        /** A constructor that creates a new [SubspaceLayoutNode]. */
+        public val Constructor: () -> SubspaceLayoutNode = { SubspaceLayoutNode() }
+
+        /** Walk up the parent hierarchy to find the closest ancestor attached to a [CoreEntity]. */
+        private fun findCoreEntityParent(node: SubspaceLayoutNode) =
+            generateSequence(node.parent) { it.parent }.firstNotNullOfOrNull { it.coreEntity }
+    }
+}
+
+internal class TailModifierNode : SubspaceModifier.Node() {
+    override fun toString(): String {
+        return "<tail>"
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierElement.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierElement.kt
new file mode 100644
index 0000000..7beba95
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierElement.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.annotation.RestrictTo
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+
+/**
+ * An abstract class for [SubspaceModifier] that creates and updates [SubspaceModifier.Node]
+ * instances.
+ *
+ * @param N The type of node that this element creates and updates.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class SubspaceModifierElement<N : SubspaceModifier.Node> : SubspaceModifier {
+    /**
+     * Creates a new node of type [SubspaceModifier.Node].
+     *
+     * @return A new node of type [SubspaceModifier.Node].
+     */
+    public abstract fun create(): N
+
+    /**
+     * Updates the given [SubspaceModifier.Node] with the current state of this element.
+     *
+     * @param node The [SubspaceModifier.Node] to update.
+     */
+    public abstract fun update(node: N)
+
+    /**
+     * Returns a hash code value for this element.
+     *
+     * @return A hash code value for this element.
+     */
+    public abstract override fun hashCode(): Int
+
+    /**
+     * Indicates whether some other object is "equal to" this one.
+     *
+     * @param other The other object to compare to.
+     * @return `true` if this object is the same as the [other] argument; `false` otherwise.
+     */
+    public abstract override fun equals(other: Any?): Boolean
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierNodeChain.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierNodeChain.kt
new file mode 100644
index 0000000..c8099ce
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceModifierNodeChain.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.xr.compose.subspace.layout.CombinedSubspaceModifier
+import androidx.xr.compose.subspace.layout.Placeable
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.findInstance
+import androidx.xr.compose.subspace.layout.traverseSelfThenAncestors
+import androidx.xr.compose.subspace.layout.traverseSelfThenDescendants
+import androidx.xr.compose.unit.VolumeConstraints
+
+private val SentinelHead =
+    object : SubspaceModifier.Node() {
+        override fun toString() = "<Head>"
+    }
+
+/** See [androidx.compose.ui.node.NodeChain] */
+internal class SubspaceModifierNodeChain(private val subspaceLayoutNode: SubspaceLayoutNode) {
+    private var current: MutableList<SubspaceModifier>? = null
+    private var buffer: MutableList<SubspaceModifier>? = null
+    internal val tail: SubspaceModifier.Node = subspaceLayoutNode.measurableLayout.tail
+    internal var head: SubspaceModifier.Node = tail
+    private var inMeasurePass: Boolean = false
+
+    private fun padChain(): SubspaceModifier.Node {
+        val currentHead = head
+        currentHead.parent = SentinelHead
+        SentinelHead.child = currentHead
+        return SentinelHead
+    }
+
+    private fun trimChain(): SubspaceModifier.Node {
+        val result = SentinelHead.child ?: tail
+        result.parent = null
+        SentinelHead.child = null
+        return result
+    }
+
+    internal fun updateFrom(modifier: SubspaceModifier) {
+        val paddedHead = padChain()
+        val before = current
+        val beforeSize = before?.size ?: 0
+        val after = modifier.fillVector(buffer ?: mutableListOf())
+        var i = 0
+
+        if (beforeSize == 0) {
+            // Common case where we are initializing the chain or the previous size is zero.
+            var node = paddedHead
+            while (i < after.size) {
+                val next = after[i]
+                val parent = node
+                node = createAndInsertNodeAsChild(next, parent)
+                i++
+            }
+        } else if (after.size == 0) {
+            // Common case where we are removing all the modifiers.
+            var node = paddedHead.child
+            while (node != null && i < beforeSize) {
+                node = removeNode(node).child
+                i++
+            }
+        } else {
+            // Find the diffs between before and after sets. This is not as complex as base Compose
+            // which
+            // does a full diff. Revisit this if we see any performance issues with dynamic
+            // modifiers.
+            var node = paddedHead
+
+            // First match as many identical modifiers at the beginning of the lists.
+            checkNotNull(before) { "prior modifier list should be non-empty" }
+            while (i < beforeSize && i < after.size && before[i] == after[i]) {
+                node = checkNotNull(node.child) { "child should not be null" }
+                i++
+            }
+
+            // Then remove the remaining existing modifiers.
+            var nodeToDelete = node.child
+            var beforeIndex = i
+            while (nodeToDelete != null && beforeIndex < beforeSize) {
+                nodeToDelete = removeNode(nodeToDelete).child
+                beforeIndex++
+            }
+
+            // Finally add the remaining new modifiers.
+            while (i < after.size) {
+                val next = after[i]
+                val parent = node
+                node = createAndInsertNodeAsChild(next, parent)
+                i++
+            }
+        }
+
+        current = after
+        // Clear the before vector to allow old modifiers to be Garbage Collected.
+        buffer = before?.also { it.clear() }
+        head = trimChain()
+    }
+
+    internal fun measureChain(
+        constraints: VolumeConstraints,
+        wrappedMeasureBlock: (VolumeConstraints) -> Placeable,
+    ): Placeable {
+        val layoutNode = getAll<SubspaceLayoutModifierNode>().firstOrNull()
+        if (layoutNode == null || inMeasurePass) {
+            inMeasurePass = false
+            return wrappedMeasureBlock(constraints)
+        }
+        inMeasurePass = true
+        val placeable = layoutNode.coordinator.measure(constraints)
+        inMeasurePass = false
+        return placeable
+    }
+
+    /** Returns all nodes of the given type in the chain in the declared modifier order. */
+    internal inline fun <reified T> getAll(): Sequence<T> =
+        head.traverseSelfThenDescendants().filterIsInstance<T>()
+
+    /**
+     * Returns the last node of the given type in the chain if it exists, null otherwise.
+     *
+     * When considering only one instance of a modifier type, prefer the last instance.
+     */
+    internal inline fun <reified T> getLast(): T? =
+        tail.traverseSelfThenAncestors().findInstance<T>()
+
+    private fun createAndInsertNodeAsChild(
+        element: SubspaceModifier,
+        parent: SubspaceModifier.Node,
+    ): SubspaceModifier.Node {
+        val node = (element as SubspaceModifierElement<*>).create()
+        if (node is SubspaceLayoutModifierNode) {
+            node.coordinator?.layoutNode = subspaceLayoutNode
+        }
+        return insertChild(node, parent)
+    }
+
+    private fun insertChild(
+        node: SubspaceModifier.Node,
+        parent: SubspaceModifier.Node,
+    ): SubspaceModifier.Node {
+        val theChild = parent.child
+        if (theChild != null) {
+            theChild.parent = node
+            node.child = theChild
+        }
+        parent.child = node
+        node.parent = parent
+        return node
+    }
+
+    private fun removeNode(node: SubspaceModifier.Node): SubspaceModifier.Node {
+        val child = node.child
+        val parent = node.parent
+        if (child != null) {
+            child.parent = parent
+            node.child = null
+        }
+        if (parent != null) {
+            parent.child = child
+            node.parent = null
+        }
+        return parent!!
+    }
+}
+
+private fun SubspaceModifier.fillVector(
+    result: MutableList<SubspaceModifier>
+): MutableList<SubspaceModifier> {
+    val capacity = result.size.coerceAtLeast(16)
+    val stack = ArrayList<SubspaceModifier>(capacity).also { it.add(this) }
+    var predicate: ((SubspaceModifier) -> Boolean)? = null
+    while (stack.isNotEmpty()) {
+        when (val next = stack.removeAt(stack.size - 1)) {
+            is CombinedSubspaceModifier -> {
+                stack.add(next.inner)
+                stack.add(next.outer)
+            }
+            is SubspaceModifierElement<*> -> result.add(next as SubspaceModifier)
+
+            // some other [androidx.compose.ui.node.Modifier] implementation that we don't know
+            // about...
+            // late-allocate the predicate only once for the entire stack
+            else ->
+                next.all(
+                    predicate
+                        ?: { element: SubspaceModifier ->
+                                result.add(element)
+                                true
+                            }
+                            .also { predicate = it }
+                )
+        }
+    }
+    return result
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceNodeApplier.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceNodeApplier.kt
new file mode 100644
index 0000000..eff0483
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceNodeApplier.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.compose.runtime.AbstractApplier
+import androidx.xr.compose.platform.Logger
+import androidx.xr.compose.unit.VolumeConstraints
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * Node applier for subspace compositions.
+ *
+ * See [androidx.compose.ui.node.UiApplier]
+ */
+internal class SubspaceNodeApplier(root: SubspaceLayoutNode) :
+    AbstractApplier<SubspaceLayoutNode>(root) {
+
+    init {
+        root.measurePolicy = SubspaceLayoutNode.RootMeasurePolicy
+    }
+
+    override fun insertTopDown(index: Int, instance: SubspaceLayoutNode) {
+        // Ignored. Insert is performed in [insertBottomUp] to build the tree bottom-up to avoid
+        // duplicate notification when the child nodes enter the tree.
+    }
+
+    override fun insertBottomUp(index: Int, instance: SubspaceLayoutNode) {
+        current.insertAt(index, instance)
+    }
+
+    override fun remove(index: Int, count: Int) {
+        current.removeAt(index, count)
+    }
+
+    override fun move(from: Int, to: Int, count: Int) {
+        current.move(from, to, count)
+    }
+
+    override fun onClear() {
+        root.removeAll()
+    }
+
+    override fun onEndChanges() {
+        val measureResults =
+            root.measurableLayout.measure(
+                VolumeConstraints(0, VolumeConstraints.INFINITY, 0, VolumeConstraints.INFINITY)
+            )
+
+        (measureResults as SubspaceLayoutNode.MeasurableLayout).placeAt(
+            Pose(Vector3.Zero, Quaternion.Identity)
+        )
+        Logger.log("SubspaceNodeApplier") { root.debugTreeToString() }
+        Logger.log("SubspaceNodeApplier") { root.debugEntityTreeToString() }
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceOwner.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceOwner.kt
new file mode 100644
index 0000000..22e5385
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceOwner.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+/**
+ * Owner interface that defines the connection to the underlying element system.
+ *
+ * On Android, this connects to Android [elements][androidx.xr.subspace.Element] and all layout,
+ * draw, input, and accessibility is hooked through them.
+ *
+ * See [androidx.compose.ui.node.Owner]
+ */
+internal interface SubspaceOwner {
+    /** The root layout node in the component tree. */
+    public val root: SubspaceLayoutNode
+
+    /**
+     * Called by [SubspaceLayoutNode] when the node is attached to this owner's element system.
+     *
+     * This is used by [SubspaceOwner] to track which nodes are associated with it. It is only
+     * called by a [node] that just got attached to this owner.
+     */
+    public fun onAttach(node: SubspaceLayoutNode)
+
+    /**
+     * Called by [SubspaceLayoutNode] when it is detached from the element system (for example
+     * during [SubspaceLayoutNode.removeAt]).
+     *
+     * @param node the node that is being detached from this owner's element system.
+     */
+    public fun onDetach(node: SubspaceLayoutNode)
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsModifierNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsModifierNode.kt
new file mode 100644
index 0000000..6ab3360
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsModifierNode.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+
+/**
+ * A [SubspaceModifier.Node] that adds semantics key/values for use in testing, accessibility, and
+ * similar use cases.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SubspaceSemanticsModifierNode {
+    /**
+     * Adds semantics key/value pairs to the layout node, for use in testing, accessibility, etc.
+     *
+     * The [SemanticsPropertyReceiver] provides "key = value"-style setters for any
+     * [SemanticsPropertyKey]. Also, chaining multiple semantics modifiers is supported.
+     */
+    public fun SemanticsPropertyReceiver.applySemantics()
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsNode.kt
new file mode 100644
index 0000000..12f58a1
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceSemanticsNode.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.semantics.SemanticsConfiguration
+import androidx.xr.compose.subspace.layout.CoreEntity
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.Component
+
+/**
+ * A list of key/value pairs associated with a layout node or its subtree.
+ *
+ * Each SubspaceSemanticsNode takes its id and initial key/value list from the outermost modifier on
+ * one layout node. It also contains the "collapsed" configuration of any other semantics modifiers
+ * on the same layout node.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SubspaceSemanticsNode
+internal constructor(private val layoutNode: SubspaceLayoutNode) {
+    /** The unique ID of this semantics node. */
+    public val id: Int = layoutNode.semanticsId
+
+    /** The size of the bounding box for this node. */
+    public val size: IntVolumeSize
+        get() = layoutNode.measurableLayout.size
+
+    /**
+     * The position of this node relative to its parent layout node in the Compose hierarchy, in
+     * pixels.
+     */
+    public val position: Vector3
+        get() = layoutNode.measurableLayout.pose.translation
+
+    /** The position of this node relative to the root of this Compose hierarchy, in pixels. */
+    public val positionInRoot: Vector3
+        get() = layoutNode.measurableLayout.poseInRoot.translation
+
+    /** The rotation of this node relative to its parent layout node in the Compose hierarchy. */
+    public val rotation: Quaternion
+        get() = layoutNode.measurableLayout.pose.rotation
+
+    /** The rotation of this node relative to the root of this Compose hierarchy. */
+    public val rotationInRoot: Quaternion
+        get() = layoutNode.measurableLayout.poseInRoot.rotation
+
+    /** The components attached to this node by SubspaceLayoutNode update. */
+    @get:Suppress("NullableCollection")
+    public val components: List<Component>?
+        get() = layoutNode.coreEntity?.entity?.getComponents()
+
+    /** The scale factor of this node relative to its parent. */
+    public val scale: Float
+        get() = layoutNode.coreEntity?.entity?.getScale() ?: 1.0f
+
+    /** The CoreEntity attached to this node. */
+    internal val coreEntity: CoreEntity?
+        get() = layoutNode.coreEntity
+
+    /**
+     * The semantics configuration of this node.
+     *
+     * This includes all properties attached as modifiers to the current layout node.
+     */
+    public val config: SemanticsConfiguration
+        get() {
+            val config = SemanticsConfiguration()
+            layoutNode.nodes.getAll<SubspaceSemanticsModifierNode>().forEach {
+                with(config) { with(it) { applySemantics() } }
+            }
+            return config
+        }
+
+    /**
+     * The children of this node in the semantics tree.
+     *
+     * The children are ordered in inverse hit test order (i.e., paint order).
+     */
+    public val children: List<SubspaceSemanticsNode>
+        get() {
+            val list: MutableList<SubspaceSemanticsNode> = mutableListOf()
+            layoutNode.fillOneLayerOfSemanticsWrappers(list)
+            return list
+        }
+
+    /** Whether this node is the root of a semantics tree. */
+    public val isRoot: Boolean
+        get() = parent == null
+
+    /** The parent of this node in the semantics tree. */
+    public val parent: SubspaceSemanticsNode?
+        get() {
+            var node: SubspaceLayoutNode? = layoutNode.parent
+            while (node != null) {
+                if (node.hasSemantics) return SubspaceSemanticsNode(node)
+                node = node.parent
+            }
+            return null
+        }
+
+    private fun SubspaceLayoutNode.fillOneLayerOfSemanticsWrappers(
+        list: MutableList<SubspaceSemanticsNode>
+    ) {
+        children.forEach { child ->
+            if (child.hasSemantics) {
+                list.add(SubspaceSemanticsNode(child))
+            } else {
+                child.fillOneLayerOfSemanticsWrappers(list)
+            }
+        }
+    }
+
+    private val SubspaceLayoutNode.hasSemantics: Boolean
+        get() = nodes.getLast<SubspaceSemanticsModifierNode>() != null
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/CoreConversions.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/CoreConversions.kt
new file mode 100644
index 0000000..5bce510
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/CoreConversions.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.compose.ui.unit.Density
+import androidx.xr.scenecore.Dimensions
+
+/**
+ * Converts this [IntVolumeSize] to a [Dimensions] object in meters, taking into account [density].
+ *
+ * @return a [Dimensions] object representing the volume size in meters.
+ */
+internal fun IntVolumeSize.toDimensionsInMeters(density: Density): Dimensions =
+    Dimensions(
+        Meter.fromPixel(width.toFloat(), density).value,
+        Meter.fromPixel(height.toFloat(), density).value,
+        Meter.fromPixel(depth.toFloat(), density).value,
+    )
+
+/**
+ * Creates an [IntVolumeSize] from a [Dimensions] object in meters.
+ *
+ * The dimensions in meters are rounded to the nearest pixel value.
+ *
+ * @param density The pixel density of the display.
+ * @return an [IntVolumeSize] object representing the same volume size in pixels.
+ */
+internal fun Dimensions.toIntVolumeSize(density: Density): IntVolumeSize =
+    IntVolumeSize(
+        Meter(this.width).roundToPx(density),
+        Meter(this.height).roundToPx(density),
+        Meter(this.depth).roundToPx(density),
+    )
+
+/**
+ * Converts this [DpVolumeSize] to a [Dimensions] object in meters.
+ *
+ * @return a [Dimensions] object representing the volume size in meters
+ */
+internal fun DpVolumeSize.toDimensionsInMeters(): Dimensions =
+    Dimensions(width.toMeter().value, height.toMeter().value, depth.toMeter().value)
+
+/**
+ * Creates a [DpVolumeSize] from a [Dimensions] object in meters.
+ *
+ * @return a [DpVolumeSize] object representing the same volume size in Dp.
+ */
+internal fun Dimensions.toDpVolumeSize(): DpVolumeSize =
+    DpVolumeSize(Meter(width).toDp(), Meter(height).toDp(), Meter(depth).toDp())
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/DpVolumeSize.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/DpVolumeSize.kt
new file mode 100644
index 0000000..8f027cb
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/DpVolumeSize.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Represents the size of a volume in density-independent pixels ([Dp]).
+ *
+ * This class provides a convenient way to store and manipulate the [width], [height], and [depth]
+ * of a 3D volume in Dp. It also provides methods to convert to and from [Dimensions] in meters.
+ *
+ * @property width the size of the volume along the x dimension, in Dp.
+ * @property height the size of the volume along the y dimension, in Dp.
+ * @property depth the size of the volume along the z dimension, in Dp. Panels have 0 depth and
+ *   cannot be set to non-zero depth.
+ */
+public class DpVolumeSize(public val width: Dp, public val height: Dp, public val depth: Dp) {
+
+    override fun toString(): String {
+        return "DpVolumeSize(width=$width, height=$height, depth=$depth)"
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is DpVolumeSize) return false
+
+        if (width != other.width) return false
+        if (height != other.height) return false
+        if (depth != other.depth) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = width.hashCode()
+        result = 31 * result + height.hashCode()
+        result = 31 * result + depth.hashCode()
+        return result
+    }
+
+    /** Contains a common constant */
+    public companion object {
+        /** A [DpVolumeSize] with all dimensions set to 0.dp. */
+        public val Zero: DpVolumeSize = DpVolumeSize(0.dp, 0.dp, 0.dp)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/IntVolumeSize.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/IntVolumeSize.kt
new file mode 100644
index 0000000..f4a0641
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/IntVolumeSize.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.xr.scenecore.Dimensions
+
+/**
+ * Represents the size of a volume in pixels.
+ *
+ * This class provides a convenient way to store and manipulate the [width], [height], and [depth]
+ * of a 3D volume in pixels. It also provides methods to convert to and from [Dimensions] in meters.
+ *
+ * Note: As with all [Int] values in Compose XR, the values in this class represent pixels.
+ *
+ * @property width the size of the volume along the x dimension, in pixels.
+ * @property height the size of the volume along the y dimension, in pixels.
+ * @property depth the size of the volume along the z dimension, in pixels. Panels have 0 depth and
+ *   cannot be set to non-zero depth.
+ */
+public class IntVolumeSize(public val width: Int, public val height: Int, public val depth: Int) {
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is IntVolumeSize) return false
+
+        if (width != other.width) return false
+        if (height != other.height) return false
+        if (depth != other.depth) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = width
+        result = 31 * result + height
+        result = 31 * result + depth
+        return result
+    }
+
+    override fun toString(): String {
+        return "IntVolumeSize(width=$width, height=$height, depth=$depth)"
+    }
+
+    /** Contains common constants and factory methods for creating [IntVolumeSize] objects */
+    public companion object {
+        /** An [IntVolumeSize] with all dimensions set to 0. */
+        public val Zero: IntVolumeSize = IntVolumeSize(0, 0, 0)
+    }
+}
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/Meter.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/Meter.kt
new file mode 100644
index 0000000..008ea95
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/Meter.kt
@@ -0,0 +1,307 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package androidx.xr.compose.unit
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isFinite
+import androidx.compose.ui.unit.isSpecified
+import androidx.xr.extensions.XrExtensions
+import androidx.xr.extensions.XrExtensionsProvider
+import kotlin.math.roundToInt
+
+/**
+ * Represents a dimension value in meters within 3D space.
+ *
+ * This is the standard unit used by the system for representing size and distance in 3D
+ * environments.
+ */
+@Immutable
+@JvmInline
+public value class Meter(public val value: Float) : Comparable<Meter> {
+
+    /**
+     * Adds another [Meter] value to this one.
+     *
+     * @param other the [Meter] value to add.
+     * @return a new [Meter] representing the sum of the two values.
+     */
+    public inline operator fun plus(other: Meter): Meter = Meter(value + other.value)
+
+    /**
+     * Subtracts another [Meter] value from this one.
+     *
+     * @param other the [Meter] value to subtract.
+     * @return a new [Meter] representing the difference between the two values.
+     */
+    public inline operator fun minus(other: Meter): Meter = Meter(value - other.value)
+
+    /**
+     * Multiplies this [Meter] value by an [Int] factor.
+     *
+     * @param other the integer factor to multiply by.
+     * @return a new [Meter] representing the product.
+     */
+    public inline operator fun times(other: Int): Meter = Meter(value * other)
+
+    /**
+     * Multiplies this [Meter] value by a [Float] factor.
+     *
+     * @param other the float factor to multiply by.
+     * @return a new [Meter] representing the product.
+     */
+    public inline operator fun times(other: Float): Meter = Meter(value * other)
+
+    /**
+     * Multiplies this [Meter] value by a [Double] factor.
+     *
+     * @param other the double factor to multiply by.
+     * @return a new [Meter] representing the product.
+     */
+    public inline operator fun times(other: Double): Meter = Meter(value * other.toFloat())
+
+    /**
+     * Divides this [Meter] value by an [Int] factor.
+     *
+     * @param other the [Int] factor to divide by.
+     * @return a new [Meter] representing the quotient.
+     */
+    public inline operator fun div(other: Int): Meter = Meter(value / other)
+
+    /**
+     * Divides this [Meter] value by a [Float] factor.
+     *
+     * @param other the [Float] factor to divide by.
+     * @return a new [Meter] representing the quotient.
+     */
+    public inline operator fun div(other: Float): Meter = Meter(value / other)
+
+    /**
+     * Divides this [Meter] value by a [Double] factor.
+     *
+     * @param other the [Double] factor to divide by.
+     * @return a new [Meter] representing the quotient.
+     */
+    public inline operator fun div(other: Double): Meter = Meter(value / other.toFloat())
+
+    /**
+     * Converts this [Meter] value to [Float] millimeters.
+     *
+     * @return the equivalent value in millimeters as a [Float].
+     */
+    public inline fun toMm(): Float = value * 1000f
+
+    /**
+     * Converts this [Meter] value to [Float] centimeters.
+     *
+     * @return the equivalent value in centimeters as a [Float].
+     */
+    public inline fun toCm(): Float = value * 100f
+
+    /**
+     * Converts this [Meter] value to [Float] meters.
+     *
+     * @return the equivalent value in meters as a [Float].
+     */
+    public inline fun toM(): Float = value
+
+    /**
+     * Converts this [Meter] value to an approximate number of pixels it contains.
+     *
+     * @return the approximate equivalent value in pixels as a [Float].
+     */
+    public inline fun toPx(density: Density): Float {
+        with(density) {
+            return toDp().toPx()
+        }
+    }
+
+    /**
+     * Converts this [Meter] value to the nearest [Int] number of pixels, taking into account
+     * [density].
+     *
+     * @return the rounded equivalent value in pixels as an [Int].
+     */
+    public inline fun roundToPx(density: Density): Int {
+        return toPx(density).roundToInt()
+    }
+
+    /**
+     * Converts this [Meter] value to the [Dp] number of density-independent pixels it contains.
+     *
+     * @return the equivalent value in [Dp].
+     */
+    public inline fun toDp(): Dp {
+        return (toM() * DP_PER_METER).dp
+    }
+
+    /**
+     * Checks if this [Meter] value is specified (i.e., not NaN).
+     *
+     * @return `true` if the value is specified, `false` otherwise.
+     */
+    public inline val isSpecified: Boolean
+        get() = !value.isNaN()
+
+    /**
+     * Checks if this [Meter] value is finite.
+     *
+     * @return `true` if the value is finite, `false` when [Meter.Infinity].
+     */
+    public inline val isFinite: Boolean
+        get() = value != Float.POSITIVE_INFINITY
+
+    /**
+     * Compares this [Meter] value to [other].
+     *
+     * @param other The other [Meter] value to compare to.
+     * @return a negative value if this [Meter] is less than [other], a positive value if it's
+     *   greater, or 0 if they are equal.
+     */
+    override fun compareTo(other: Meter): Int {
+        return value.compareTo(other.value)
+    }
+
+    public companion object {
+        /**
+         * If we can't look up the DPs per meter from the system, we will use this value. This value
+         * was measured on the current Android XR device and will need to be updated if the device's
+         * config changes.
+         */
+        private const val DP_PER_METER_FALLBACK: Float = 1151.856f
+
+        /**
+         * DPs per meter. The system's API is in pixels, but we can get the value we want be
+         * specifying 1 dp == 1 pixel.
+         */
+        @PublishedApi
+        internal val DP_PER_METER: Float =
+            tryGetXrExtensions()?.config?.defaultPixelsPerMeter(1.0f) ?: DP_PER_METER_FALLBACK
+
+        /** Represents an infinite distance in meters. */
+        public val Infinity: Meter = Meter(Float.POSITIVE_INFINITY)
+
+        /** Represents an undefined or unrepresentable distance in meters. */
+        public val NaN: Meter = Meter(Float.NaN)
+
+        /**
+         * Attempts to retrieve an instance of [XrExtensions].
+         *
+         * @return an instance of [XrExtensions] if available, or [null] otherwise.
+         */
+        private fun tryGetXrExtensions(): XrExtensions? =
+            try {
+                XrExtensionsProvider.getXrExtensions()
+            } catch (e: Exception) {
+                null
+            } catch (e: Error) {
+                null
+            }
+
+        /**
+         * Creates a [Meter] value from a given number of pixels.
+         *
+         * @param px the number of pixels.
+         * @param density The pixel density of the display.
+         * @return a [Meter] value representing the equivalent distance in meters.
+         */
+        public inline fun fromPixel(px: Float, density: Density): Meter {
+            with(density) {
+                // We do the conversion inline instead of calling Dp.toMeter(), which will check its
+                // inputs.
+                // We know if the input is an integer pixel, we won't have any exceptional Dp
+                // values, e.g.,
+                // Dp.Infinity.
+                return Meter(px.toDp().value / DP_PER_METER)
+            }
+        }
+
+        // Primitive conversion functions for creating [Meter] values from various units.
+
+        /** Creates a [Meter] from the [Int] millimeter value. */
+        public val Int.millimeters: Meter
+            get() = Meter(this.toFloat() * 0.001f)
+
+        /** Creates a [Meter] from the [Float] millimeter value. */
+        public val Float.millimeters: Meter
+            get() = Meter(this * 0.001f)
+
+        /** Creates a [Meter] from the [Double] millimeter value. */
+        public val Double.millimeters: Meter
+            get() = Meter(this.toFloat() * 0.001f)
+
+        /** Creates a [Meter] from the [Int] centimeter value. */
+        public val Int.centimeters: Meter
+            get() = Meter(this * 0.01f)
+
+        /** Creates a [Meter] from the [Float] centimeter value. */
+        public val Float.centimeters: Meter
+            get() = Meter(this * 0.01f)
+
+        /** Creates a [Meter] from the [Double] centimeter value. */
+        public val Double.centimeters: Meter
+            get() = Meter(this.toFloat() * 0.01f)
+
+        /** Creates a [Meter] from the [Int] meter value. */
+        public val Int.meters: Meter
+            get() = Meter(this.toFloat())
+
+        /** Creates a [Meter] from the [Float] meter value. */
+        public val Float.meters: Meter
+            get() = Meter(this)
+
+        /** Creates a [Meter] from the [Double] meter value. */
+        public val Double.meters: Meter
+            get() = Meter(this.toFloat())
+    }
+}
+
+/**
+ * Converts a [Dp] value to [Meter].
+ *
+ * Handles unspecified and infinite [Dp] values gracefully.
+ *
+ * @return the equivalent value in meters, or [Meter.NaN] if the [Dp] is unspecified, or
+ *   [Meter.Infinity] if the [Dp] is infinite.
+ */
+public inline fun Dp.toMeter(): Meter {
+    if (!isSpecified) {
+        return Meter.NaN
+    }
+    if (!isFinite) {
+        return Meter.Infinity
+    }
+    return Meter(this.value / Meter.DP_PER_METER)
+}
+
+// Operator functions for performing arithmetic operations between numeric types and Meter
+
+public inline operator fun Int.times(other: Meter): Meter = Meter(this * other.value)
+
+public inline operator fun Float.times(other: Meter): Meter = Meter(this * other.value)
+
+public inline operator fun Double.times(other: Meter): Meter = Meter(this.toFloat() * other.value)
+
+public inline operator fun Int.div(other: Meter): Meter = Meter(this / other.value)
+
+public inline operator fun Float.div(other: Meter): Meter = Meter(this / other.value)
+
+public inline operator fun Double.div(other: Meter): Meter = Meter(this.toFloat() / other.value)
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/VolumeConstraints.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/VolumeConstraints.kt
new file mode 100644
index 0000000..50d8427
--- /dev/null
+++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/unit/VolumeConstraints.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+/**
+ * Defines constraints for a 3D volume, specifying minimum and maximum values for width, height, and
+ * depth.
+ *
+ * This class is similar in concept to [androidx.compose.ui.unit.Constraints], but adapted for 3D
+ * volumes.
+ *
+ * @property minWidth the minimum allowed width in pixels.
+ * @property maxWidth the maximum allowed width in pixels, use [INFINITY] to indicate no maximum.
+ * @property minHeight the minimum allowed height in pixels.
+ * @property maxHeight the maximum allowed height in pixels. Can be [INFINITY] to indicate no
+ *   maximum.
+ * @property minDepth the minimum allowed depth in pixels. Defaults to 0.
+ * @property maxDepth the maximum allowed depth in pixels. Can be [INFINITY] to indicate no maximum.
+ *   Defaults to [INFINITY].
+ */
+public class VolumeConstraints(
+    public val minWidth: Int,
+    public val maxWidth: Int,
+    public val minHeight: Int,
+    public val maxHeight: Int,
+    public val minDepth: Int = 0,
+    public val maxDepth: Int = INFINITY,
+) {
+
+    /** Indicates whether the width is bounded (has a maximum value other than [INFINITY]). */
+    @get:JvmName("hasBoundedWidth")
+    public val hasBoundedWidth: Boolean
+        get() {
+            return maxWidth != INFINITY
+        }
+
+    /** Indicates whether the height is bounded (has a maximum value other than [INFINITY]). */
+    @get:JvmName("hasBoundedHeight")
+    public val hasBoundedHeight: Boolean
+        get() {
+            return maxHeight != INFINITY
+        }
+
+    /** Indicates whether the depth is bounded (has a maximum value other than [INFINITY]). */
+    @get:JvmName("hasBoundedDepth")
+    public val hasBoundedDepth: Boolean
+        get() {
+            return maxDepth != INFINITY
+        }
+
+    /** Returns a string representation of the [VolumeConstraints]. */
+    override fun toString(): String =
+        "width: $minWidth-$maxWidth, height: $minHeight-$maxHeight, depth=$minDepth-$maxDepth"
+
+    /** Checks if this [VolumeConstraints] object is equal to [other] object. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is VolumeConstraints) return false
+
+        if (minWidth != other.minWidth) return false
+        if (maxWidth != other.maxWidth) return false
+        if (minHeight != other.minHeight) return false
+        if (maxHeight != other.maxHeight) return false
+        if (minDepth != other.minDepth) return false
+        if (maxDepth != other.maxDepth) return false
+
+        return true
+    }
+
+    /** Calculates a hash code for this [VolumeConstraints] object. */
+    override fun hashCode(): Int {
+        var result = minWidth
+        result = 31 * result + maxWidth
+        result = 31 * result + minHeight
+        result = 31 * result + maxHeight
+        result = 31 * result + minDepth
+        result = 31 * result + maxDepth
+        return result
+    }
+
+    /**
+     * Creates a copy of this [VolumeConstraints] object with modifications to its properties.
+     *
+     * @param minWidth the minimum allowed width in the new constraints.
+     * @param maxWidth the maximum allowed width in the new constraints.
+     * @param minHeight the minimum allowed height in the new constraints.
+     * @param maxHeight the maximum allowed height in the new constraints.
+     * @param minDepth the minimum allowed depth in the new constraints.
+     * @param maxDepth the maximum allowed depth in the new constraints.
+     * @return a new [VolumeConstraints] object with the specified modifications.
+     */
+    public fun copy(
+        minWidth: Int = this.minWidth,
+        maxWidth: Int = this.maxWidth,
+        minHeight: Int = this.minHeight,
+        maxHeight: Int = this.maxHeight,
+        minDepth: Int = this.minDepth,
+        maxDepth: Int = this.maxDepth,
+    ): VolumeConstraints =
+        VolumeConstraints(
+            minWidth = minWidth,
+            maxWidth = maxWidth,
+            minHeight = minHeight,
+            maxHeight = maxHeight,
+            minDepth = minDepth,
+            maxDepth = maxDepth,
+        )
+
+    public companion object {
+        /** Represents an unbounded (infinite) constraint value. */
+        public const val INFINITY: Int = Int.MAX_VALUE
+    }
+}
+
+/**
+ * Constrains the dimensions of this [VolumeConstraints] object to fit within the bounds of the
+ * other [VolumeConstraints] object.
+ *
+ * @param otherConstraints the other [VolumeConstraints] to constrain against.
+ * @return a new [VolumeConstraints] object with dimensions constrained within the bounds of
+ *   [otherConstraints].
+ */
+public fun VolumeConstraints.constrain(otherConstraints: VolumeConstraints): VolumeConstraints =
+    VolumeConstraints(
+        minWidth = otherConstraints.minWidth.coerceIn(minWidth, maxWidth),
+        maxWidth = otherConstraints.maxWidth.coerceIn(minWidth, maxWidth),
+        minHeight = otherConstraints.minHeight.coerceIn(minHeight, maxHeight),
+        maxHeight = otherConstraints.maxHeight.coerceIn(minHeight, maxHeight),
+        minDepth = otherConstraints.minDepth.coerceIn(minDepth, maxDepth),
+        maxDepth = otherConstraints.maxDepth.coerceIn(minDepth, maxDepth),
+    )
+
+/**
+ * Constrains a given [width] value to fit within the bounds of this [VolumeConstraints] object.
+ *
+ * @param width the width value to constrain.
+ * @return the constrained width value, ensuring it's within the minimum and maximum width bounds.
+ */
+public fun VolumeConstraints.constrainWidth(width: Int): Int = width.coerceIn(minWidth, maxWidth)
+
+/**
+ * Constrains a given height value to fit within the bounds of this [VolumeConstraints] object.
+ *
+ * @param height the height value to constrain.
+ * @return the constrained height value, ensuring it's within the minimum and maximum height bounds.
+ */
+public fun VolumeConstraints.constrainHeight(height: Int): Int =
+    height.coerceIn(minHeight, maxHeight)
+
+/**
+ * Constrains a given depth value to fit within the bounds of this [VolumeConstraints] object.
+ *
+ * @param depth the depth value to constrain.
+ * @return the constrained depth value, ensuring it's within the minimum and maximum depth bounds.
+ */
+public fun VolumeConstraints.constrainDepth(depth: Int): Int = depth.coerceIn(minDepth, maxDepth)
+
+/**
+ * Creates a new [VolumeConstraints] object by offsetting the minimum and maximum values of this
+ * one.
+ *
+ * @param horizontal the horizontal offset to apply.
+ * @param vertical the vertical offset to apply.
+ * @param depth the depth offset to apply.
+ * @param resetMins if true, the minimum values in the new constraints will be set to 0, otherwise.
+ *   they will be offset.
+ * @return a new [VolumeConstraints] object with offset values.
+ */
+public fun VolumeConstraints.offset(
+    horizontal: Int = 0,
+    vertical: Int = 0,
+    depth: Int = 0,
+    resetMins: Boolean = false,
+): VolumeConstraints =
+    VolumeConstraints(
+        if (resetMins) 0 else (minWidth + horizontal).coerceAtLeast(0),
+        addMaxWithMinimum(maxWidth, horizontal),
+        if (resetMins) 0 else (minHeight + vertical).coerceAtLeast(0),
+        addMaxWithMinimum(maxHeight, vertical),
+        if (resetMins) 0 else (minDepth + depth).coerceAtLeast(0),
+        addMaxWithMinimum(maxDepth, depth),
+    )
+
+/** Adds a value to a maximum value, ensuring it stays within the minimum bound. */
+private fun addMaxWithMinimum(max: Int, value: Int): Int {
+    return if (max == VolumeConstraints.INFINITY) {
+        max
+    } else {
+        (max + value).coerceAtLeast(0)
+    }
+}
diff --git a/xr/compose/compose/src/test/AndroidManifest.xml b/xr/compose/compose/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..665ec06
--- /dev/null
+++ b/xr/compose/compose/src/test/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          xmlns:tools="http://schemas.android.com/tools">
+
+    <application>
+        <activity android:name="androidx.xr.compose.testing.SubspaceTestingActivity"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialCapabilitiesTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialCapabilitiesTest.kt
new file mode 100644
index 0000000..62fd4bb
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialCapabilitiesTest.kt
@@ -0,0 +1,316 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpatialCapabilitiesTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun isSpatialUiEnabled_xrNotEnabled_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isSpatialUiEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isContent3dEnabled_xrNotEnabled_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isContent3dEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isAppEnvironmentEnabled_xrNotEnabled_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isAppEnvironmentEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isPassthroughControlEnabled_xrNotEnabled_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isPassthroughControlEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isSpatialAudioEnabled_xrNotEnabled_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isSpatialAudioEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isSpatialUiEnabled_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup { Text(text = "${LocalSpatialCapabilities.current.isSpatialUiEnabled}") }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isContent3dEnabled_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup { Text(text = "${LocalSpatialCapabilities.current.isContent3dEnabled}") }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isAppEnvironmentEnabled_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup { Text(text = "${LocalSpatialCapabilities.current.isAppEnvironmentEnabled}") }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isPassthroughControlEnabled_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup {
+                Text(text = "${LocalSpatialCapabilities.current.isPassthroughControlEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isSpatialAudioEnabled_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup { Text(text = "${LocalSpatialCapabilities.current.isSpatialAudioEnabled}") }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isSpatialUiEnabled_homeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text("${LocalSpatialCapabilities.current.isSpatialUiEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isSpatialUiEnabled_homeSpaceMode_requestFullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text("${LocalSpatialCapabilities.current.isSpatialUiEnabled}")
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isSpatialUiEnabled_fullSpaceMode_requestHomeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                Text("${LocalSpatialCapabilities.current.isSpatialUiEnabled}")
+                requestHomeSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isContent3dEnabled_homeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Text("${LocalSpatialCapabilities.current.isContent3dEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isContent3dEnabled_homeSpaceMode_requestFullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text("${LocalSpatialCapabilities.current.isContent3dEnabled}")
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isContent3dEnabled_fullSpaceMode_requestHomeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                Text("${LocalSpatialCapabilities.current.isContent3dEnabled}")
+                requestHomeSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isAppEnvironmentEnabled_homeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Text(text = "${LocalSpatialCapabilities.current.isAppEnvironmentEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isAppEnvironmentEnabled_homeSpaceMode_requestFullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isAppEnvironmentEnabled}")
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isAppEnvironmentEnabled_fullSpaceMode_requestHomeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                Text(text = "${LocalSpatialCapabilities.current.isAppEnvironmentEnabled}")
+                requestHomeSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isPassthroughControlEnabled_homeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Text(text = "${LocalSpatialCapabilities.current.isPassthroughControlEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isPassthroughControlEnabled_homeSpaceMode_requestFullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isPassthroughControlEnabled}")
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isPassthroughControlEnabled_fullSpaceMode_requestHomeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                Text(text = "${LocalSpatialCapabilities.current.isPassthroughControlEnabled}")
+                requestHomeSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isSpatialAudioEnabled_homeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Text(text = "${LocalSpatialCapabilities.current.isSpatialAudioEnabled}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun isSpatialAudioEnabled_homeSpaceMode_requestFullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text(text = "${LocalSpatialCapabilities.current.isSpatialAudioEnabled}")
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun isSpatialAudioEnabled_fullSpaceMode_requestHomeSpaceMode_returnsFalse() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                Text(text = "${LocalSpatialCapabilities.current.isSpatialAudioEnabled}")
+                requestHomeSpaceMode()
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialConfigurationTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialConfigurationTest.kt
new file mode 100644
index 0000000..97dfa73
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/platform/SpatialConfigurationTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.platform
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import androidx.xr.compose.unit.DpVolumeSize
+import com.google.common.truth.Truth.assertThat
+import java.lang.UnsupportedOperationException
+import kotlin.test.assertFailsWith
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpatialConfigurationTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun hasXrSpatialFeature_nonXr_isFalse() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Text(text = "${LocalSpatialConfiguration.current.hasXrSpatialFeature}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${false}").assertExists()
+    }
+
+    @Test
+    fun requestFullSpaceMode_nonXr_throwsException() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                assertFailsWith<UnsupportedOperationException> {
+                    LocalSpatialConfiguration.current.requestFullSpaceMode()
+                }
+            }
+        }
+    }
+
+    @Test
+    fun requestHomeSpaceMode_nonXr_throwsException() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                assertFailsWith<UnsupportedOperationException> {
+                    LocalSpatialConfiguration.current.requestHomeSpaceMode()
+                }
+            }
+        }
+    }
+
+    @Test
+    fun requestModeChange_changesBounds() {
+        var configuration: SpatialConfiguration? = null
+
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                configuration = LocalSpatialConfiguration.current
+                if (configuration?.bounds == DpVolumeSize(Dp.Infinity, Dp.Infinity, Dp.Infinity)) {
+                    Text("Full")
+                } else {
+                    Text("Home")
+                }
+            }
+        }
+
+        composeTestRule.onNodeWithText("Full").assertExists()
+        configuration?.requestHomeSpaceMode()
+        composeTestRule.onNodeWithText("Home").assertExists()
+        configuration?.requestFullSpaceMode()
+        composeTestRule.onNodeWithText("Full").assertExists()
+    }
+
+    @Test
+    fun hasXrSpatialFeature_fullSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup { Text(text = "${LocalSpatialConfiguration.current.hasXrSpatialFeature}") }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun hasXrSpatialFeature_homeSpaceMode_returnsTrue() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                Text(text = "${LocalSpatialConfiguration.current.hasXrSpatialFeature}")
+            }
+        }
+
+        composeTestRule.onNodeWithText("${true}").assertExists()
+    }
+
+    @Test
+    fun bounds_homeSpaceMode_isPositiveAndNotMax() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = false) {
+                assertThat(LocalSpatialConfiguration.current.bounds.width).isNotEqualTo(Dp.Infinity)
+                assertThat(LocalSpatialConfiguration.current.bounds.width).isGreaterThan(0.dp)
+                assertThat(LocalSpatialConfiguration.current.bounds.height)
+                    .isNotEqualTo(Dp.Infinity)
+                assertThat(LocalSpatialConfiguration.current.bounds.height).isGreaterThan(0.dp)
+                assertThat(LocalSpatialConfiguration.current.bounds.depth).isNotEqualTo(Dp.Infinity)
+                assertThat(LocalSpatialConfiguration.current.bounds.depth).isGreaterThan(0.dp)
+            }
+        }
+    }
+
+    @Test
+    fun bounds_fullSpaceMode_isMax() {
+        composeTestRule.setContent {
+            TestSetup(isFullSpace = true) {
+                assertThat(LocalSpatialConfiguration.current.bounds.width).isEqualTo(Dp.Infinity)
+                assertThat(LocalSpatialConfiguration.current.bounds.height).isEqualTo(Dp.Infinity)
+                assertThat(LocalSpatialConfiguration.current.bounds.depth).isEqualTo(Dp.Infinity)
+            }
+        }
+    }
+
+    @Test
+    fun bounds_nonXr_equalsViewSize() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                // 320x470 is the default screen size returned by the testing architecture.
+                assertThat(LocalSpatialConfiguration.current.bounds.width).isEqualTo(320.dp)
+                assertThat(LocalSpatialConfiguration.current.bounds.height).isEqualTo(470.dp)
+                assertThat(LocalSpatialConfiguration.current.bounds.depth).isEqualTo(0.dp)
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/OrbiterTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/OrbiterTest.kt
new file mode 100644
index 0000000..61eb28b
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/OrbiterTest.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.spatial.EdgeOffset.Companion.inner
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class OrbiterTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun orbiter_contentIsElevated() {
+        composeTestRule.setContent {
+            TestSetup { Parent { Orbiter(OrbiterEdge.Top) { Text("Main Content") } } }
+        }
+
+        composeTestRule.onNodeWithText("Main Content").assertExists()
+        composeTestRule.onParent().onChild().assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_nonXr_contentIsInline() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Parent { Orbiter(OrbiterEdge.Top) { Text("Main Content") } }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertTextContains("Main Content")
+    }
+
+    @Test
+    fun orbiter_homeSpaceMode_contentIsInline() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Parent { Orbiter(OrbiterEdge.Top) { Text("Main Content") } }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertTextContains("Main Content")
+    }
+
+    @Test
+    fun orbiter_nonSpatial_doesNotRenderContent() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Parent {
+                    Orbiter(
+                        OrbiterEdge.Top,
+                        settings = OrbiterSettings(shouldRenderInNonSpatial = false)
+                    ) {
+                        Text("Main Content")
+                    }
+                }
+            }
+        }
+
+        composeTestRule.onNodeWithText("Main Content").assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_multipleInstances_rendersInSpatial() {
+        composeTestRule.setContent {
+            TestSetup {
+                Parent {
+                    Orbiter(position = OrbiterEdge.Top) { Text("Top") }
+                    Orbiter(position = OrbiterEdge.Start) { Text("Start") }
+                    Orbiter(position = OrbiterEdge.End) { Text("End") }
+                    Orbiter(position = OrbiterEdge.Bottom) { Text("Bottom") }
+                }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_afterSwitchToFullSpaceMode_isSpatial() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Parent { Orbiter(position = OrbiterEdge.Bottom) { Text("Bottom") } }
+                requestFullSpaceMode()
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_setting_contentIsNotInline() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Parent {
+                    Orbiter(
+                        OrbiterEdge.Top,
+                        settings = OrbiterSettings(shouldRenderInNonSpatial = false)
+                    ) {
+                        Text("Main Content")
+                    }
+                }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_settingChange_contentIsInline() {
+        var shouldRenderInNonSpatial by mutableStateOf(false)
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Parent {
+                    Orbiter(
+                        OrbiterEdge.Top,
+                        settings =
+                            OrbiterSettings(shouldRenderInNonSpatial = shouldRenderInNonSpatial),
+                    ) {
+                        Text("Main Content")
+                    }
+                }
+            }
+        }
+
+        shouldRenderInNonSpatial = true
+
+        composeTestRule.onParent().onChild().assertTextContains("Main Content")
+    }
+
+    @Test
+    fun orbiter_orbiterRendered() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Box {
+                    Text("Main Content")
+                    Orbiter(OrbiterEdge.Start) { Text("Orbiter Content") }
+                }
+            }
+        }
+
+        composeTestRule.onNodeWithText("Main Content").assertExists()
+        composeTestRule.onNodeWithText("Orbiter Content").assertExists()
+    }
+
+    @Test
+    fun orbiter_orbiterCanBeRemoved() {
+        var showOrbiter by mutableStateOf(true)
+
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Box(modifier = Modifier.size(100.dp)) {
+                    Text("Main Content")
+                    if (showOrbiter) {
+                        Orbiter(position = OrbiterEdge.Top) { Text("Top Orbiter Content") }
+                    }
+                }
+            }
+        }
+
+        composeTestRule.onNodeWithText("Top Orbiter Content").assertExists()
+        showOrbiter = false
+        composeTestRule.onNodeWithText("Top Orbiter Content").assertDoesNotExist()
+    }
+
+    @Test
+    fun orbiter_orbiterRenderedInlineInHomeSpaceMode() {
+        var isFullSpaceMode by mutableStateOf(true)
+
+        composeTestRule.setContent {
+            TestSetup {
+                LaunchedEffect(isFullSpaceMode) {
+                    if (isFullSpaceMode) {
+                        requestFullSpaceMode()
+                    } else {
+                        requestHomeSpaceMode()
+                    }
+                }
+
+                Parent {
+                    Box(modifier = Modifier.size(100.dp)) { Text("Main Content") }
+                    Orbiter(position = OrbiterEdge.Top, offset = inner(0.dp)) {
+                        Text("Top Orbiter Content")
+                    }
+                    Orbiter(position = OrbiterEdge.Start) { Text("Start Orbiter Content") }
+                    Orbiter(position = OrbiterEdge.Bottom) { Text("Bottom Orbiter Content") }
+                    Orbiter(position = OrbiterEdge.End) { Text("End Orbiter Content") }
+                }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertTextContains("Main Content")
+        isFullSpaceMode = false
+        // All orbiters become children of the Parent node
+        composeTestRule.onParent().onChildren().assertCountEquals(5)
+        isFullSpaceMode = true
+        // Orbiters exist outside of the compose hierarchy
+        composeTestRule.onParent().onChildren().assertCountEquals(1)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/Parent.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/Parent.kt
new file mode 100644
index 0000000..80de3d9
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/Parent.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onNodeWithTag
+
+@Composable
+internal fun Parent(content: @Composable () -> Unit) {
+    Box(modifier = Modifier.testTag("parent")) { content() }
+}
+
+internal fun ComposeContentTestRule.onParent() = onNodeWithTag("parent")
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SpatialElevationTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SpatialElevationTest.kt
new file mode 100644
index 0000000..97b741d
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SpatialElevationTest.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.Popup
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import kotlin.test.assertFailsWith
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpatialElevationTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun spatialElevation_mainContent_isComposed() {
+        composeTestRule.setContent {
+            TestSetup {
+                SpatialElevation {
+                    Box(modifier = Modifier.size(100.dp).testTag("MainContent")) {
+                        Text("Main Content")
+                    }
+                }
+            }
+        }
+
+        composeTestRule.onNodeWithTag("MainContent").assertExists()
+    }
+
+    @Test
+    fun spatialElevation_dialog_throwsError() {
+        assertFailsWith<RuntimeException> {
+            composeTestRule.setContent {
+                TestSetup { SpatialElevation { Dialog(onDismissRequest = {}) { Text("Title") } } }
+            }
+        }
+    }
+
+    @Test
+    fun spatialElevation_popup_throwsError() {
+        assertFailsWith<RuntimeException> {
+            composeTestRule.setContent {
+                TestSetup { SpatialElevation { Popup { Text("Popup") } } }
+            }
+        }
+    }
+
+    @Test
+    fun spatialElevation_xrNotSupported_doesNotThrowError() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) { SpatialElevation { Popup { Text("Popup") } } }
+        }
+
+        composeTestRule.onNodeWithText("Popup").assertExists()
+    }
+
+    @Test
+    fun spatialElevation_homeSpaceMode_doesNotElevate() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestHomeSpaceMode()
+                Parent { SpatialElevation { Text("Main Content") } }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertTextContains("Main Content")
+    }
+
+    @Test
+    fun spatialElevation_fullSpaceMode_doesElevate() {
+        composeTestRule.setContent {
+            TestSetup {
+                requestFullSpaceMode()
+                Parent { SpatialElevation { Text("Main Content") } }
+            }
+        }
+
+        composeTestRule.onParent().onChild().assertDoesNotExist()
+        composeTestRule.onNodeWithText("Main Content").assertExists()
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SubspaceTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SubspaceTest.kt
new file mode 100644
index 0000000..81677f9
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/spatial/SubspaceTest.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.spatial
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.platform.SceneManager
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.TestSetup
+import androidx.xr.compose.testing.assertPositionInRootIsEqualTo
+import androidx.xr.compose.testing.createFakeRuntime
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SubspaceTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun subspace_xrEnabled_contentIsCreated() {
+        composeTestRule.setContent {
+            TestSetup { Subspace { SpatialPanel(SubspaceModifier.testTag("panel")) {} } }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+    }
+
+    @Test
+    fun subspace_nonXr_contentIsNotCreated() {
+        composeTestRule.setContent {
+            TestSetup(isXrEnabled = false) {
+                Subspace { SpatialPanel(SubspaceModifier.testTag("panel")) {} }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("panel").assertDoesNotExist()
+    }
+
+    @Test
+    fun subspace_applicationSubspace_contentIsParentedToActivitySpace() {
+        composeTestRule.setContent {
+            TestSetup { Subspace { SpatialPanel(SubspaceModifier.testTag("panel")) {} } }
+        }
+
+        val node = composeTestRule.onSubspaceNodeWithTag("panel").fetchSemanticsNode()
+        assertThat(node.coreEntity?.entity?.getParent())
+            .isEqualTo(composeTestRule.activity.session.activitySpace)
+    }
+
+    @Test
+    fun subspace_nestedSubspace_contentIsParentedToContainingPanel() {
+        composeTestRule.setContent {
+            TestSetup {
+                Subspace {
+                    SpatialPanel(SubspaceModifier.testTag("panel")) {
+                        Subspace { SpatialPanel(SubspaceModifier.testTag("innerPanel")) {} }
+                    }
+                }
+            }
+        }
+
+        val panelNode = composeTestRule.onSubspaceNodeWithTag("panel").fetchSemanticsNode()
+        val innerPanelNode =
+            composeTestRule.onSubspaceNodeWithTag("innerPanel").fetchSemanticsNode()
+        val innerPanelEntity = innerPanelNode.coreEntity?.entity
+        val subspaceRootEntity = innerPanelEntity?.getParent()
+        val subspaceRootContainerEntity = subspaceRootEntity?.getParent()
+        val parentPanel = subspaceRootContainerEntity?.getParent()
+        assertThat(parentPanel).isNotNull()
+        assertThat(parentPanel).isEqualTo(panelNode.coreEntity?.entity)
+    }
+
+    @Test
+    fun subspace_isDisposed() {
+        var showSubspace by mutableStateOf(true)
+
+        composeTestRule.setContent {
+            TestSetup {
+                if (showSubspace) {
+                    Subspace { SpatialPanel(SubspaceModifier.testTag("panel")) {} }
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        assertThat(SceneManager.getSceneCount()).isEqualTo(1)
+        showSubspace = false
+        composeTestRule.onSubspaceNodeWithTag("panel").assertDoesNotExist()
+        assertThat(SceneManager.getSceneCount()).isEqualTo(0)
+    }
+
+    @Test
+    fun subspace_onlyOneSceneExists_afterSpaceModeChanges() {
+        val runtime = createFakeRuntime(composeTestRule.activity)
+
+        composeTestRule.setContent {
+            TestSetup(runtime = runtime) {
+                Subspace { SpatialPanel(SubspaceModifier.testTag("panel")) {} }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        assertThat(SceneManager.getSceneCount()).isEqualTo(1)
+        runtime.requestHomeSpaceMode()
+        composeTestRule.onSubspaceNodeWithTag("panel").assertDoesNotExist()
+        assertThat(SceneManager.getSceneCount()).isEqualTo(0)
+        runtime.requestFullSpaceMode()
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        assertThat(SceneManager.getSceneCount()).isEqualTo(1)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialBoxTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialBoxTest.kt
new file mode 100644
index 0000000..dafaff6
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialBoxTest.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.size
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertHeightIsEqualTo
+import androidx.xr.compose.testing.assertPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertWidthIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SpatialBox]. */
+@RunWith(AndroidJUnit4::class)
+class SpatialBoxTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun spatialBox_elementsAreCenteredByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialBox(SubspaceModifier.size(100.dp)) {
+                SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                    Text(text = "Panel 1")
+                }
+                SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                    Text(text = "Panel 2")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun spatialBox_elementsAreAlignedWithBoxSpatialAlignment_topLeft() {
+        composeTestRule.setSubspaceContent {
+            SpatialBox(SubspaceModifier.size(100.dp), alignment = SpatialAlignment.TopLeft) {
+                SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                    Text(text = "Panel 1")
+                }
+                SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                    Text(text = "Panel 2")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(-25.dp, 25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(-25.dp, 25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun spatialBox_elementsAreAlignedWithBoxSpatialAlignment_bottomRight() {
+        composeTestRule.setSubspaceContent {
+            SpatialBox(SubspaceModifier.size(100.dp), alignment = SpatialAlignment.BottomRight) {
+                SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                    Text(text = "Panel 1")
+                }
+                SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                    Text(text = "Panel 2")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(25.dp, -25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(25.dp, -25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun spatialBox_elementsAreAlignedWithModifier() {
+        composeTestRule.setSubspaceContent {
+            SpatialBox(SubspaceModifier.size(100.dp)) {
+                SpatialPanel(
+                    SubspaceModifier.testTag("panel1")
+                        .size(50.dp)
+                        .align(SpatialAlignment.BottomLeft)
+                ) {
+                    Text(text = "Panel 1")
+                }
+                SpatialPanel(
+                    SubspaceModifier.testTag("panel2").size(50.dp).align(SpatialAlignment.TopRight)
+                ) {
+                    Text(text = "Panel 2")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(-25.dp, -25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(25.dp, 25.dp, 0.dp)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun spatialBox_elementsHonorPropagatedMinConstraints() {
+        composeTestRule.setSubspaceContent {
+            SpatialBox(SubspaceModifier.size(100.dp), propagateMinConstraints = true) {
+                SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) {
+                    Text(text = "Panel 1")
+                }
+                SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) {
+                    Text(text = "Panel 2")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(100.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(100.dp)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialPanelTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialPanelTest.kt
new file mode 100644
index 0000000..972f2ca
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialPanelTest.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import android.view.View
+import android.widget.TextView
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.layout.CorePanelEntity
+import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.height
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.subspace.layout.width
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SpatialPanel]. */
+@RunWith(AndroidJUnit4::class)
+class SpatialPanelTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun spatialPanel_internalElementsAreLaidOutProperly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.width(100.dp).testTag("panel")) {
+                // Row with 2 elements, one is 3x as large as the other
+                Row {
+                    Spacer(Modifier.testTag("spacer1").weight(1f))
+                    Spacer(Modifier.testTag("spacer2").weight(3f))
+                }
+            }
+        }
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        composeTestRule.onNodeWithTag("spacer1").assertWidthIsEqualTo(25.dp)
+        composeTestRule.onNodeWithTag("spacer2").assertWidthIsEqualTo(75.dp)
+    }
+
+    @Test
+    fun spatialPanel_textTooLong_panelDoesNotGrowBeyondSpecifiedWidth() {
+        composeTestRule.setSubspaceContent {
+            // Panel with 10dp width, way too small for the text we're putting into it
+            SpatialPanel(SubspaceModifier.width(10.dp).testTag("panel")) {
+                // Panel contains a column.
+                Column {
+                    Text("Hello World long text", style = MaterialTheme.typography.headlineLarge)
+                }
+            }
+        }
+        // Text element stays 10dp long, even though it needs more space, as the Panel will not grow
+        // for the text.
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        composeTestRule.onNodeWithText("Hello World long text").assertWidthIsEqualTo(10.dp)
+    }
+
+    @Test
+    fun spatialPanel_viewBasedPanelComposes() {
+        composeTestRule.setSubspaceContent {
+            val context = LocalContext.current
+            val textView = remember { TextView(context).apply { text = "Hello World" } }
+            SpatialPanel(view = textView, SubspaceModifier.testTag("panel"))
+            // The View is not inserted in the compose tree, we need to test it differentlly
+            assertEquals(View.VISIBLE, textView.visibility)
+        }
+        // TODO: verify that the TextView is add to the Panel
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+    }
+
+    @Test
+    fun mainPanel_renders() {
+        val text = "Main Window Text"
+        composeTestRule.setSubspaceContent({ Text(text) }) {
+            MainPanel(SubspaceModifier.testTag("panel"))
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("panel").assertExists()
+        composeTestRule.onNodeWithText(text).assertExists()
+    }
+
+    @Test
+    fun mainPanel_addedTwice_asserts() {
+        val text = "Main Window Text"
+
+        assertThrows(IllegalStateException::class.java) {
+            composeTestRule.setSubspaceContent({ Text(text) }) {
+                MainPanel(SubspaceModifier.testTag("panel"))
+                MainPanel(SubspaceModifier.testTag("panel2"))
+            }
+        }
+    }
+
+    @Test
+    fun mainPanel_addedTwiceInDifferentSubtrees_asserts() {
+        val text = "Main Window Text"
+
+        assertThrows(IllegalStateException::class.java) {
+            composeTestRule.setSubspaceContent({ Text(text) }) {
+                SpatialColumn {
+                    SpatialRow { MainPanel(SubspaceModifier.testTag("panel")) }
+                    SpatialRow { MainPanel(SubspaceModifier.testTag("panel2")) }
+                }
+            }
+        }
+    }
+
+    @Test
+    fun spatialPanel_cornerRadius_dp() {
+        val density = Density(1.0f)
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                modifier = SubspaceModifier.width(200.dp).height(300.dp).testTag("panel"),
+                shape = SpatialRoundedCornerShape(CornerSize(32.dp)),
+            ) {}
+        }
+
+        assertThat(getCorePanelEntity("panel")?.getCornerRadius(density)).isEqualTo(32f)
+    }
+
+    @Test
+    fun spatialPanel_cornerRadius_percent() {
+        val density = Density(1.0f)
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                modifier = SubspaceModifier.width(200.dp).height(300.dp).testTag("panel"),
+                shape = SpatialRoundedCornerShape(CornerSize(50)),
+            ) {}
+        }
+
+        // 50 percent of the shorter side (200.dp) at 1.0 Density is 100 pixels.
+        assertThat(getCorePanelEntity("panel")?.getCornerRadius(density)).isEqualTo(100f)
+    }
+
+    @Test
+    fun spatialPanel_cornerRadius_increasedDensity() {
+        val density = Density(3.0f)
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                modifier = SubspaceModifier.width(200.dp).height(300.dp).testTag("panel"),
+                shape = SpatialRoundedCornerShape(CornerSize(50)),
+            ) {}
+        }
+
+        // 50 percent of the shorter side (200.dp) at 3.0 Density is 300 pixels.
+        assertThat(getCorePanelEntity("panel")?.getCornerRadius(density)).isEqualTo(300f)
+    }
+
+    private fun getCorePanelEntity(tag: String): CorePanelEntity? {
+        return composeTestRule.onSubspaceNodeWithTag(tag).fetchSemanticsNode().coreEntity
+            as? CorePanelEntity
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialRowColumnTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialRowColumnTest.kt
new file mode 100644
index 0000000..bae11de
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SpatialRowColumnTest.kt
@@ -0,0 +1,457 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.layout.SpatialAlignment
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.offset
+import androidx.xr.compose.subspace.layout.size
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.subspace.layout.width
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertHeightIsEqualTo
+import androidx.xr.compose.testing.assertLeftPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertRotationInRootIsEqualTo
+import androidx.xr.compose.testing.assertTopPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertWidthIsEqualTo
+import androidx.xr.compose.testing.assertXPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertYPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertZPositionInRootIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.runtime.math.Quaternion
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SpatialRow] and [SpatialColumn]. */
+@RunWith(AndroidJUnit4::class)
+class SpatialRowColumnTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun spatialRowColumn_internalElementsAreLaidOutProperly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(10.dp)) {
+                // This column will get the first 7dp
+                SpatialColumn(SubspaceModifier.testTag("column1").width(7.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                // There are only 3dp left, so this column will end up being 3dp
+                SpatialColumn(SubspaceModifier.testTag("column2").width(7.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertLeftPositionInRootIsEqualTo(-5.dp)
+            .assertXPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(10.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertLeftPositionInRootIsEqualTo(-5.dp)
+            .assertWidthIsEqualTo(7.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertLeftPositionInRootIsEqualTo(2.dp)
+            .assertWidthIsEqualTo(3.dp)
+    }
+
+    @Test
+    fun spatialRow_internalElementsAreAligned() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(
+                SubspaceModifier.testTag("row1").size(20.dp),
+                alignment = SpatialAlignment.CenterLeft,
+            ) {
+                SpatialColumn(SubspaceModifier.testTag("column1").size(5.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(SubspaceModifier.testTag("column2").size(5.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertLeftPositionInRootIsEqualTo(-10.dp)
+            .assertXPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(20.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertLeftPositionInRootIsEqualTo(-10.dp)
+            .assertWidthIsEqualTo(5.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertLeftPositionInRootIsEqualTo(-5.dp)
+            .assertWidthIsEqualTo(5.dp)
+    }
+
+    @Test
+    fun spatialRow_internalElementsAreAligned_withModifier() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(
+                SubspaceModifier.testTag("row1").size(20.dp),
+                alignment = SpatialAlignment.CenterLeft,
+            ) {
+                SpatialColumn(
+                    SubspaceModifier.testTag("column1").size(10.dp).align(SpatialAlignment.Top)
+                ) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(
+                    SubspaceModifier.testTag("column2").size(10.dp).align(SpatialAlignment.Front)
+                ) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("column1").assertYPositionInRootIsEqualTo(5.dp)
+
+        composeTestRule.onSubspaceNodeWithTag("column2").assertZPositionInRootIsEqualTo(5.dp)
+    }
+
+    @Test
+    fun spatialColumn_internalElementsAreAligned() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(
+                SubspaceModifier.testTag("column1").size(20.dp),
+                alignment = SpatialAlignment.TopCenter,
+            ) {
+                SpatialRow(SubspaceModifier.testTag("row1").size(5.dp)) {
+                    SpatialPanel { Text(text = "SpatialRow 1") }
+                }
+                SpatialRow(SubspaceModifier.testTag("row2").size(5.dp)) {
+                    SpatialPanel { Text(text = "SpatialRow 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertTopPositionInRootIsEqualTo(10.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertHeightIsEqualTo(20.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertTopPositionInRootIsEqualTo(10.dp)
+            .assertHeightIsEqualTo(5.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row2")
+            .assertTopPositionInRootIsEqualTo(5.dp)
+            .assertHeightIsEqualTo(5.dp)
+    }
+
+    @Test
+    fun spatialColumn_internalElementsAreAligned_withModifier() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(
+                SubspaceModifier.testTag("column1").size(20.dp),
+                alignment = SpatialAlignment.TopCenter,
+            ) {
+                SpatialRow(
+                    SubspaceModifier.testTag("row1").size(10.dp).align(SpatialAlignment.Left)
+                ) {
+                    SpatialPanel { Text(text = "SpatialRow 1") }
+                }
+                SpatialRow(
+                    SubspaceModifier.testTag("row2").size(10.dp).align(SpatialAlignment.Back)
+                ) {
+                    SpatialPanel { Text(text = "SpatialRow 2") }
+                }
+            }
+        }
+
+        composeTestRule.onSubspaceNodeWithTag("row1").assertXPositionInRootIsEqualTo(-5.dp)
+
+        composeTestRule.onSubspaceNodeWithTag("row2").assertZPositionInRootIsEqualTo(-5.dp)
+    }
+
+    @Test
+    fun spatialRowColumn_twoWeightBasedChildren_internalElementsAreLaidOutProperly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(1000.dp)) {
+                // 25% width (250dp)
+                SpatialColumn(SubspaceModifier.testTag("column1").weight(1f)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                // 75% width (750dp)
+                SpatialColumn(SubspaceModifier.testTag("column2").weight(3f)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertLeftPositionInRootIsEqualTo(-500.dp)
+            .assertXPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(1000.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertLeftPositionInRootIsEqualTo(-500.dp)
+            .assertWidthIsEqualTo(250.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertLeftPositionInRootIsEqualTo(-250.dp)
+            .assertWidthIsEqualTo(750.dp)
+    }
+
+    @Test
+    fun spatialRowColumn_oneFixedAndTwoWeightBasedChildren_internalElementsAreLaidOutProperly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(1000.dp)) {
+                // 250dp fixed width
+                SpatialColumn(SubspaceModifier.testTag("column1").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                // 1/5th of the remaining 750dp (150dp)
+                SpatialColumn(SubspaceModifier.testTag("column2").weight(1f)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+                // 4/5th of the remaining 750dp (600dp)
+                SpatialColumn(SubspaceModifier.testTag("column3").weight(4f)) {
+                    SpatialPanel { Text(text = "Column 3") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertLeftPositionInRootIsEqualTo(-500.dp)
+            .assertXPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(1000.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertLeftPositionInRootIsEqualTo(-500.dp)
+            .assertWidthIsEqualTo(250.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertLeftPositionInRootIsEqualTo(-250.dp)
+            .assertWidthIsEqualTo(150.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column3")
+            .assertLeftPositionInRootIsEqualTo(-100.dp)
+            .assertWidthIsEqualTo(600.dp)
+    }
+
+    @Test
+    fun spatialRowColumn_weightCalculationRemainderIsAppliedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            // 200dp row, 7 children:
+            // 200 / 7 = 28.57, which gets rounded to 29dp.
+            // 29 * 7 = 203dp, so the first 3 children should have 1dp removed from each of them.
+            SpatialRow(SubspaceModifier.testTag("row1").width(200.dp)) {
+                // 28dp (1dp remainder removed)
+                SpatialColumn(SubspaceModifier.testTag("column1").weight(1f)) { SpatialPanel {} }
+                // 28dp (1dp remainder removed)
+                SpatialColumn(SubspaceModifier.testTag("column2").weight(1f)) { SpatialPanel {} }
+                // 28dp (1dp remainder removed)
+                SpatialColumn(SubspaceModifier.testTag("column3").weight(1f)) { SpatialPanel {} }
+                // 29dp
+                SpatialColumn(SubspaceModifier.testTag("column4").weight(1f)) { SpatialPanel {} }
+                // 29dp
+                SpatialColumn(SubspaceModifier.testTag("column5").weight(1f)) { SpatialPanel {} }
+                // 29dp
+                SpatialColumn(SubspaceModifier.testTag("column6").weight(1f)) { SpatialPanel {} }
+                // 29dp
+                SpatialColumn(SubspaceModifier.testTag("column7").weight(1f)) { SpatialPanel {} }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("row1")
+            .assertLeftPositionInRootIsEqualTo(-100.dp)
+            .assertXPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(200.dp)
+
+        var currentLeftPosition = -100.dp
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(28.dp)
+        currentLeftPosition += 28.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(28.dp)
+        currentLeftPosition += 28.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column3")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(28.dp)
+        currentLeftPosition += 28.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column4")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(29.dp)
+        currentLeftPosition += 29.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column5")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(29.dp)
+        currentLeftPosition += 29.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column6")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(29.dp)
+        currentLeftPosition += 29.dp
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column7")
+            .assertLeftPositionInRootIsEqualTo(currentLeftPosition)
+            .assertWidthIsEqualTo(29.dp)
+    }
+
+    @Test
+    fun spatialRowColumn_negativeCurvatureIsIgnored() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(500.dp), curveRadius = -100.dp) {
+                SpatialColumn(SubspaceModifier.testTag("column1").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(SubspaceModifier.testTag("column2").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertXPositionInRootIsEqualTo(-125.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(0.dp) // No curvature.
+            .assertRotationInRootIsEqualTo(Quaternion.Identity)
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertXPositionInRootIsEqualTo(125.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(0.dp) // No curvature.
+            .assertRotationInRootIsEqualTo(Quaternion.Identity)
+    }
+
+    @Test
+    fun spatialRowColumn_zeroCurvatureIsIgnored() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(500.dp), curveRadius = 0.dp) {
+                SpatialColumn(SubspaceModifier.testTag("column1").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(SubspaceModifier.testTag("column2").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        // Verify that the row is flat, i.e., it has no curvature.
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertXPositionInRootIsEqualTo(-125.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(0.dp) // No curvature.
+            .assertRotationInRootIsEqualTo(Quaternion.Identity)
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertXPositionInRootIsEqualTo(125.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(0.dp) // No curvature.
+            .assertRotationInRootIsEqualTo(Quaternion.Identity)
+    }
+
+    @Test
+    fun spatialRowColumn_positiveCurvatureCreatesCurvature() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row1").width(500.dp), curveRadius = 100.dp) {
+                SpatialColumn(SubspaceModifier.testTag("column1").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(SubspaceModifier.testTag("column2").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertXPositionInRootIsEqualTo(-94.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(68.dp)
+            .assertRotationInRootIsEqualTo(Quaternion(0.0f, 0.58509725f, 0.0f, 0.8109631f))
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertXPositionInRootIsEqualTo(94.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(68.dp)
+            .assertRotationInRootIsEqualTo(Quaternion(0.0f, -0.58509725f, 0.0f, 0.8109631f))
+    }
+
+    @Test
+    fun spatialRowColumn_zOffsetIsRespected() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(
+                SubspaceModifier.testTag("row1").width(500.dp).offset(0.dp, 0.dp, -50.dp),
+                curveRadius = 100.dp,
+            ) {
+                SpatialColumn(SubspaceModifier.testTag("column1").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 1") }
+                }
+                SpatialColumn(SubspaceModifier.testTag("column2").width(250.dp)) {
+                    SpatialPanel { Text(text = "Column 2") }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column1")
+            .assertXPositionInRootIsEqualTo(-94.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(18.dp) // Offset by -50.dp.
+            .assertRotationInRootIsEqualTo(Quaternion(0.0f, 0.58509725f, 0.0f, 0.8109631f))
+
+        composeTestRule
+            .onSubspaceNodeWithTag("column2")
+            .assertXPositionInRootIsEqualTo(94.dp)
+            .assertYPositionInRootIsEqualTo(0.dp)
+            .assertZPositionInRootIsEqualTo(18.dp) // Offset by -50.dp.
+            .assertRotationInRootIsEqualTo(Quaternion(0.0f, -0.58509725f, 0.0f, 0.8109631f))
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityTest.kt
new file mode 100644
index 0000000..badc30c
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityTest.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import java.lang.IllegalArgumentException
+import kotlin.test.assertFailsWith
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CoreEntityTest {
+
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun coreEntity_coreContentlessEntity_shouldThrowIfNotContentless() {
+        composeTestRule.setContent {}
+
+        assertFailsWith<IllegalArgumentException> {
+            CoreContentlessEntity(composeTestRule.activity.session.activitySpace)
+        }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MeasureScopeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MeasureScopeTest.kt
new file mode 100644
index 0000000..0ed4c6f
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MeasureScopeTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+class TestMeasureScope : MeasureScope {
+    override val density: Float
+        get() = 2f
+}
+
+/** Tests for [MeasureScope]. */
+@RunWith(AndroidJUnit4::class)
+class MeasureScopeTest {
+    @Test
+    fun toPx_convertsCorrectly() {
+        with(TestMeasureScope()) {
+            assertThat(Dp(0f).toPx()).isEqualTo(0f)
+            assertThat(Dp(10.3f).toPx()).isEqualTo(20.6f)
+            assertThat(Dp(-10.3f).toPx()).isEqualTo(-20.6f)
+            assertThat(Dp(Float.POSITIVE_INFINITY).toPx()).isEqualTo(Float.POSITIVE_INFINITY)
+            assertThat(Dp(Float.NEGATIVE_INFINITY).toPx()).isEqualTo(Float.NEGATIVE_INFINITY)
+        }
+    }
+
+    @Test
+    fun roundToPx_convertsCorrectly() {
+        with(TestMeasureScope()) {
+            assertThat(Dp(0f).roundToPx()).isEqualTo(0)
+            assertThat(Dp(10.3f).roundToPx()).isEqualTo(21)
+            assertThat(Dp(-10.3f).roundToPx()).isEqualTo(-21)
+            assertThat(Dp(Float.POSITIVE_INFINITY).roundToPx()).isEqualTo(Constraints.Infinity)
+            assertThat(Dp(Float.NEGATIVE_INFINITY).roundToPx()).isEqualTo(Constraints.Infinity)
+        }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MovableModifierTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MovableModifierTest.kt
new file mode 100644
index 0000000..cd21291
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/MovableModifierTest.kt
@@ -0,0 +1,454 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialColumn
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.SpatialRow
+import androidx.xr.compose.subspace.Volume
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.scenecore.MovableComponent
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [Movable] modifier. */
+@RunWith(AndroidJUnit4::class)
+class MovableModifierTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun movable_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel")) { Text(text = "Panel") }
+        }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("panel")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun movable_componentIsNotNullAndOnlyContainsSingleMovable() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").movable()) { Text(text = "Panel") }
+        }
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierIsDisabledAndComponentDoesNotExist() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").movable(false)) { Text(text = "Panel") }
+        }
+
+        assertMovableComponentDoesNotExist()
+    }
+
+    @Test
+    fun movable_modifierDoesNotChangeAndOnlyOneComponentExist() {
+        composeTestRule.setSubspaceContent {
+            var panelWidth by remember { mutableStateOf(50.dp) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel").width(panelWidth).movable(enabled = true)
+            ) {
+                Button(modifier = Modifier.testTag("button"), onClick = { panelWidth += 50.dp }) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose there should still only exist one Component.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierEnabledToDisabledAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(true) }
+            SpatialPanel(SubspaceModifier.testTag("panel").movable(enabled = movableEnabled)) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { movableEnabled = !movableEnabled },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose no Components should exist.
+        assertMovableComponentDoesNotExist()
+    }
+
+    @Test
+    fun movable_modifierOnPoseChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = true, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { onPoseReturnValue = !onPoseReturnValue },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose there should only exist one Component, not necessarily the same as
+        // before.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierDisableWithOnPoseChangeUpdateAndComponentRemoved() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(true) }
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = movableEnabled, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        movableEnabled = !movableEnabled
+                        onPoseReturnValue = !onPoseReturnValue
+                    },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose Component should be removed.
+        assertMovableComponentDoesNotExist()
+    }
+
+    @Test
+    fun movable_modifierEnabledWithOnPoseChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(false) }
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = movableEnabled, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        movableEnabled = !movableEnabled
+                        onPoseReturnValue = !onPoseReturnValue
+                    },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertMovableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose Component should exist and be attached.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierDisabledThenEnabledAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(true) }
+            SpatialPanel(SubspaceModifier.testTag("panel").movable(enabled = movableEnabled)) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { movableEnabled = !movableEnabled },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose Component should not exist.
+        assertMovableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose Component should be attached.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierOnPoseChangeTwiceUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = true, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { onPoseReturnValue = !onPoseReturnValue },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose there should only exist one Component, not necessarily the same as
+        // before.
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose there should only exist one Component, not necessarily the same as
+        // before.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierDisabledThenEnabledWithOnPoseChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(true) }
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = movableEnabled, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        movableEnabled = !movableEnabled
+                        onPoseReturnValue = !onPoseReturnValue
+                    },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose removes Component.
+        assertMovableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose Component should be attached. There should only exist one
+        // Component,
+        // not necessarily the same as before.
+        assertSingleMovableComponentExist()
+    }
+
+    @Test
+    fun movable_modifierEnabledThenDisabledWithOnPoseChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var movableEnabled by remember { mutableStateOf(false) }
+            var onPoseReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .movable(enabled = movableEnabled, onPoseChange = { onPoseReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        movableEnabled = !movableEnabled
+                        onPoseReturnValue = !onPoseReturnValue
+                    },
+                ) {
+                    Text(text = "Sample button for testing")
+                }
+            }
+        }
+
+        assertMovableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose Component should be attached. There should only exist one
+        // Component,
+        // not necessarily the same as before.
+        assertSingleMovableComponentExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose removes Component.
+        assertMovableComponentDoesNotExist()
+    }
+
+    @Test
+    fun movable_columnEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column")) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("column")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun movable_columnEntity_noComponentWhenMovableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column").movable()) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertMovableComponentDoesNotExist("column")
+    }
+
+    @Test
+    fun movable_columnEntity_noComponentWhenMovableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column").movable(false)) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertMovableComponentDoesNotExist("column")
+    }
+
+    @Test
+    fun movable_rowEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row")) { SpatialPanel { Text(text = "Row") } }
+        }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("row")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun movable_rowEntity_noComponentWhenMovableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row").movable()) {
+                SpatialPanel { Text(text = "Row") }
+            }
+        }
+        assertMovableComponentDoesNotExist("row")
+    }
+
+    @Test
+    fun movable_rowEntity_noComponentWhenMovableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row").movable(false)) {
+                SpatialPanel { Text(text = "Row") }
+            }
+        }
+        assertMovableComponentDoesNotExist("row")
+    }
+
+    @Test
+    fun movable_volumeEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent { Volume(SubspaceModifier.testTag("volume")) {} }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("volume")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun movable_volumeEntity_noComponentWhenMovableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            Volume(SubspaceModifier.testTag("volume").movable()) {}
+        }
+        assertMovableComponentDoesNotExist("volume")
+    }
+
+    @Test
+    fun movable_volumeEntity_noComponentWhenMovableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            Volume(SubspaceModifier.testTag("volume").movable(false)) {}
+        }
+        assertMovableComponentDoesNotExist("volume")
+    }
+
+    private fun assertSingleMovableComponentExist(testTag: String = "panel") {
+        val components =
+            composeTestRule.onSubspaceNodeWithTag(testTag).fetchSemanticsNode().components
+        assertNotNull(components)
+        assertEquals(1, components.size)
+        assertIs<MovableComponent>(components[0])
+    }
+
+    private fun assertMovableComponentDoesNotExist(testTag: String = "panel") {
+        val components =
+            composeTestRule.onSubspaceNodeWithTag(testTag).fetchSemanticsNode().components
+        assertNotNull(components)
+        assertEquals(0, components.size)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OffsetTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OffsetTest.kt
new file mode 100644
index 0000000..c8edacf
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OffsetTest.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialColumn
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.SpatialRow
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertPositionIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [offset] modifier. */
+@RunWith(AndroidJUnit4::class)
+class OffsetTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun offset_positiveValuesArePositionedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").offset(20.dp, 20.dp, 20.dp)) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(20.dp, 20.dp, 20.dp)
+    }
+
+    @Test
+    fun offset_negativeValuesArePositionedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").offset(-20.dp, -20.dp, -20.dp)) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(-20.dp, -20.dp, -20.dp)
+    }
+
+    @Test
+    fun offset_combinedWithOtherModifiersArePositionedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .width(100.dp)
+                    .offset(10.dp, 10.dp, 10.dp)
+                    .height(100.dp)
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(10.dp, 10.dp, 10.dp)
+    }
+
+    @Test
+    fun offset_nestedLayoutsArePositionedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.width(1000.dp)) {
+                SpatialColumn(SubspaceModifier.weight(1f)) {
+                    SpatialPanel(SubspaceModifier.testTag("panel1").offset(10.dp, 10.dp, 10.dp)) {
+                        Text(text = "Panel 1")
+                    }
+                }
+                SpatialColumn(SubspaceModifier.weight(1f)) {
+                    SpatialPanel(SubspaceModifier.testTag("panel2").offset(10.dp, 10.dp, 10.dp)) {
+                        Text(text = "Panel 2")
+                    }
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel1")
+            .assertPositionInRootIsEqualTo(-240.dp, 10.dp, 10.dp) // x=-(1000/2/2) + 10
+            .assertPositionIsEqualTo(10.dp, 10.dp, 10.dp)
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel2")
+            .assertPositionInRootIsEqualTo(260.dp, 10.dp, 10.dp) // x=(1000/2/2) + 10
+            .assertPositionIsEqualTo(10.dp, 10.dp, 10.dp)
+    }
+
+    @Test
+    fun offset_updatesDynamically() {
+        composeTestRule.setSubspaceContent {
+            var offsetX by remember { mutableStateOf(0.dp) }
+            SpatialPanel(SubspaceModifier.testTag("panel").offset(x = offsetX)) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { offsetX = offsetX + 10.dp }
+                ) {
+                    Text(text = "Click to change offset")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+        composeTestRule.onNodeWithTag("button").performClick()
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(10.dp, 0.dp, 0.dp)
+        composeTestRule.onNodeWithTag("button").performClick().performClick().performClick()
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(40.dp, 0.dp, 0.dp)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifierTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifierTest.kt
new file mode 100644
index 0000000..c964c64
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/OnGloballyPositionedModifierTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.compose.testing.toDp
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [onGloballyPositioned] modifier. */
+@RunWith(AndroidJUnit4::class)
+class OnGloballyPositionedModifierTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun onGloballyPositioned_positionIsAlwaysSet() {
+        var coordinates: SubspaceLayoutCoordinates? = null
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.offset(20.dp, 20.dp, 20.dp).onGloballyPositioned {
+                    coordinates = it
+                    assertEquals(20.dp, coordinates?.poseInRoot?.translation?.x?.toDp())
+                    assertEquals(20.dp, coordinates?.poseInRoot?.translation?.y?.toDp())
+                    assertEquals(20.dp, coordinates?.poseInRoot?.translation?.z?.toDp())
+                }
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        assertNotNull(coordinates)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/PaddingTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/PaddingTest.kt
new file mode 100644
index 0000000..2835dc7
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/PaddingTest.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertDepthIsEqualTo
+import androidx.xr.compose.testing.assertHeightIsEqualTo
+import androidx.xr.compose.testing.assertPositionInRootIsEqualTo
+import androidx.xr.compose.testing.assertWidthIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import kotlin.test.assertFailsWith
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [padding] modifier. */
+@RunWith(AndroidJUnit4::class)
+class PaddingTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun padding_settingValuesIndependentlySizesCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .size(100.dp)
+                    .padding(
+                        left = 20.dp,
+                        top = 10.dp,
+                        right = 10.dp,
+                        bottom = 20.dp,
+                        front = 10.dp,
+                        back = 20.dp,
+                    )
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(5.dp, 5.dp, 5.dp)
+            .assertWidthIsEqualTo(70.dp)
+            .assertHeightIsEqualTo(70.dp)
+            .assertDepthIsEqualTo(70.dp)
+    }
+
+    @Test
+    fun padding_settingDirectionalValuesSizesCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .size(100.dp)
+                    .padding(horizontal = 20.dp, vertical = 20.dp, depth = 20.dp)
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(60.dp)
+            .assertHeightIsEqualTo(60.dp)
+            .assertDepthIsEqualTo(60.dp)
+    }
+
+    @Test
+    fun padding_settingAllValuesSizesCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").size(100.dp).padding(all = 20.dp)) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp)
+            .assertWidthIsEqualTo(60.dp)
+            .assertHeightIsEqualTo(60.dp)
+            .assertDepthIsEqualTo(60.dp)
+    }
+
+    @Test
+    fun padding_negativePaddingThrowsException() {
+        assertFailsWith<IllegalArgumentException> {
+            composeTestRule.setSubspaceContent {
+                SpatialPanel(SubspaceModifier.testTag("panel").size(100.dp).padding(top = -20.dp)) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ResizableModifierTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ResizableModifierTest.kt
new file mode 100644
index 0000000..9ead8ea
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ResizableModifierTest.kt
@@ -0,0 +1,562 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialColumn
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.SpatialRow
+import androidx.xr.compose.subspace.Volume
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.compose.unit.Meter.Companion.meters
+import androidx.xr.scenecore.ResizableComponent
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [Resizable] modifier.
+ *
+ * TODO(b/354723161): Add tests for minimum and maximum size.
+ */
+@RunWith(AndroidJUnit4::class)
+class ResizableModifierTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun resizable_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel")) { Text(text = "Panel") }
+        }
+
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("panel")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun resizable_componentIsNotNullAndOnlyContainsSingleResizable() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable()) { Text(text = "Panel") }
+        }
+
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierIsDisabledAndComponentDoesNotExist() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable(enabled = false)) {
+                Text(text = "Panel")
+            }
+        }
+
+        assertResizableComponentDoesNotExist()
+    }
+
+    @Test
+    fun resizable_modifierDoesNotChangeAndComponentDoesNotUpdate() {
+        composeTestRule.setSubspaceContent {
+            var panelWidth by remember { mutableStateOf(50.dp) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel").width(panelWidth).resizable(enabled = true)
+            ) {
+                Button(modifier = Modifier.testTag("button"), onClick = { panelWidth += 50.dp }) {
+                    Text(text = "Click to change width")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose we should continue to have the same component.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierEnabledToDisabledAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(true) }
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable(enabled = resizableEnabled)) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { resizableEnabled = !resizableEnabled },
+                ) {
+                    Text(text = "Click to change resizable")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose no Components should exist.
+        assertResizableComponentDoesNotExist()
+    }
+
+    @Test
+    fun resizable_modifierOnSizeChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = true, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { onSizeReturnValue = !onSizeReturnValue },
+                ) {
+                    Text(text = "Click to change onSizeChange")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose we should still have one Component.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierDisableWithOnSizeChangeUpdateAndComponentRemoved() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(true) }
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = resizableEnabled, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        resizableEnabled = !resizableEnabled
+                        onSizeReturnValue = !onSizeReturnValue
+                    },
+                ) {
+                    Text(text = "Click to change resizable and onSizeChange")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose Component should be removed.
+        assertResizableComponentDoesNotExist()
+    }
+
+    @Test
+    fun resizable_modifierEnabledWithOnSizeChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(false) }
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = resizableEnabled, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        resizableEnabled = !resizableEnabled
+                        onSizeReturnValue = !onSizeReturnValue
+                    },
+                ) {
+                    Text(text = "Click to change resizable and onSizeChange")
+                }
+            }
+        }
+
+        assertResizableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose Component should exist and be attached.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierDisabledThenEnabledAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(true) }
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable(enabled = resizableEnabled)) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { resizableEnabled = !resizableEnabled },
+                ) {
+                    Text(text = "Click to change resizable")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose Component should not exist.
+        assertResizableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose Component should be attached.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierOnSizeChangeTwiceUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = true, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = { onSizeReturnValue = !onSizeReturnValue },
+                ) {
+                    Text(text = "Click to change onSizeChange")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose should only have one Component.
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After recompose should only have one Component.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierDisabledThenEnabledWithOnSizeChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(true) }
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = resizableEnabled, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        resizableEnabled = !resizableEnabled
+                        onSizeReturnValue = !onSizeReturnValue
+                    },
+                ) {
+                    Text(text = "Click to change resizabe and onSizeChange")
+                }
+            }
+        }
+
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose removes Component.
+        assertResizableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose removes Component.
+        assertSingleResizableComponentExists()
+    }
+
+    @Test
+    fun resizable_modifierEnabledThenDisabledWithOnSizeChangeUpdateAndComponentUpdates() {
+        composeTestRule.setSubspaceContent {
+            var resizableEnabled by remember { mutableStateOf(false) }
+            var onSizeReturnValue by remember { mutableStateOf(true) }
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .resizable(enabled = resizableEnabled, onSizeChange = { onSizeReturnValue })
+            ) {
+                Button(
+                    modifier = Modifier.testTag("button"),
+                    onClick = {
+                        resizableEnabled = !resizableEnabled
+                        onSizeReturnValue = !onSizeReturnValue
+                    },
+                ) {
+                    Text(text = "Click to change resizabe and onSizeChange")
+                }
+            }
+        }
+
+        assertResizableComponentDoesNotExist()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After enabled, recompose removes Component.
+        assertSingleResizableComponentExists()
+
+        composeTestRule.onNodeWithTag("button").performClick()
+
+        // After disabled, recompose removes Component.
+        assertResizableComponentDoesNotExist()
+    }
+
+    @Test
+    fun resizable_modifierMaxSizeIsSet() {
+        val maxSize = DpVolumeSize(500.dp, 500.dp, 500.dp)
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable(maximumSize = maxSize)) {}
+        }
+        assertResizableComponentMaxSizeIsSet(size = maxSize)
+    }
+
+    @Test
+    fun resizable_modifierMaxSizeIsNotSet() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable()) {}
+        }
+        assertResizableComponentMaxSizeIsNotSet()
+    }
+
+    @Test
+    fun resizable_modifierMinSizeIsSet() {
+        val minSize = DpVolumeSize(100.dp, 100.dp, 100.dp)
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable(minimumSize = minSize)) {}
+        }
+        assertResizableComponentMinSizeIsSet(size = minSize)
+    }
+
+    @Test
+    fun resizable_modifierMinSizeIsNotSet() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").resizable()) {}
+        }
+        assertResizableComponentMinSizeIsNotSet()
+    }
+
+    @Test
+    fun resizable_columnEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column")) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("column")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun resizable_columnEntity_noComponentWhenResizableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column").resizable()) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertResizableComponentDoesNotExist("column")
+    }
+
+    @Test
+    fun resizable_columnEntity_noComponentWhenResizableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialColumn(SubspaceModifier.testTag("column").resizable(false)) {
+                SpatialPanel { Text(text = "Column") }
+            }
+        }
+        assertResizableComponentDoesNotExist("column")
+    }
+
+    @Test
+    fun resizable_rowEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row")) { SpatialPanel { Text(text = "Row") } }
+        }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("row")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun resizable_rowEntity_noComponentWhenResizableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row").resizable()) {
+                SpatialPanel { Text(text = "Row") }
+            }
+        }
+        assertResizableComponentDoesNotExist("row")
+    }
+
+    @Test
+    fun resizable_rowEntity_noComponentWhenResizableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.testTag("row").resizable(false)) {
+                SpatialPanel { Text(text = "Row") }
+            }
+        }
+        assertResizableComponentDoesNotExist("row")
+    }
+
+    @Test
+    fun resizable_volumeEntity_noComponentByDefault() {
+        composeTestRule.setSubspaceContent { Volume(SubspaceModifier.testTag("volume")) {} }
+        assertTrue(
+            composeTestRule
+                .onSubspaceNodeWithTag("volume")
+                .fetchSemanticsNode()
+                .components
+                .isNullOrEmpty()
+        )
+    }
+
+    @Test
+    fun resizable_volumeEntity_noComponentWhenResizableIsEnabled() {
+        composeTestRule.setSubspaceContent {
+            Volume(SubspaceModifier.testTag("volume").resizable()) {}
+        }
+        assertResizableComponentDoesNotExist("volume")
+    }
+
+    @Test
+    fun resizable_volumeEntity_noComponentWhenResizableIsDisabled() {
+        composeTestRule.setSubspaceContent {
+            Volume(SubspaceModifier.testTag("volume").resizable(false)) {}
+        }
+        assertResizableComponentDoesNotExist("volume")
+    }
+
+    private fun assertSingleResizableComponentExists(testTag: String = "panel") {
+        val components =
+            composeTestRule.onSubspaceNodeWithTag(testTag).fetchSemanticsNode().components
+        assertNotNull(components)
+        assertEquals(1, components.size)
+        assertIs<ResizableComponent>(components[0])
+    }
+
+    private fun assertResizableComponentDoesNotExist(testTag: String = "panel") {
+        val components =
+            composeTestRule.onSubspaceNodeWithTag(testTag).fetchSemanticsNode().components
+        assertNotNull(components)
+        assertEquals(0, components.size)
+    }
+
+    private fun assertResizableComponentMaxSizeIsSet(
+        testTag: String = "panel",
+        size: DpVolumeSize
+    ) {
+        val resizableComponent =
+            composeTestRule
+                .onSubspaceNodeWithTag(testTag)
+                .fetchSemanticsNode()
+                .getLastComponent<ResizableComponent>()
+
+        val maxWidth = resizableComponent.maximumSize.width.meters.toDp()
+        val maxHeight = resizableComponent.maximumSize.height.meters.toDp()
+
+        assertEquals(size.width, maxWidth)
+        assertEquals(size.height, maxHeight)
+    }
+
+    private fun assertResizableComponentMaxSizeIsNotSet(testTag: String = "panel") {
+        val resizableComponent =
+            composeTestRule
+                .onSubspaceNodeWithTag(testTag)
+                .fetchSemanticsNode()
+                .getLastComponent<ResizableComponent>()
+
+        val maxWidth = resizableComponent.maximumSize.width.meters.toDp()
+        val maxHeight = resizableComponent.maximumSize.height.meters.toDp()
+
+        assertEquals(Dp.Infinity, maxWidth)
+        assertEquals(Dp.Infinity, maxHeight)
+    }
+
+    private fun assertResizableComponentMinSizeIsSet(
+        testTag: String = "panel",
+        size: DpVolumeSize
+    ) {
+        val resizableComponent =
+            composeTestRule
+                .onSubspaceNodeWithTag(testTag)
+                .fetchSemanticsNode()
+                .getLastComponent<ResizableComponent>()
+
+        val minWidth = resizableComponent.minimumSize.width.meters.toDp()
+        val minHeight = resizableComponent.minimumSize.height.meters.toDp()
+
+        assertEquals(size.width, minWidth)
+        assertEquals(size.height, minHeight)
+    }
+
+    private fun assertResizableComponentMinSizeIsNotSet(testTag: String = "panel") {
+        val resizableComponent =
+            composeTestRule
+                .onSubspaceNodeWithTag(testTag)
+                .fetchSemanticsNode()
+                .getLastComponent<ResizableComponent>()
+
+        val minWidth = resizableComponent.minimumSize.width.meters.toDp()
+        val minHeight = resizableComponent.minimumSize.height.meters.toDp()
+
+        assertEquals(DpVolumeSize.Zero.width, minWidth)
+        assertEquals(DpVolumeSize.Zero.height, minHeight)
+    }
+
+    private inline fun <reified T> SubspaceSemanticsNode.getLastComponent(): T {
+        assertNotNull(components)
+        val component = components!!.last()
+        assertIs<T>(component)
+        return component
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/RotateTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/RotateTest.kt
new file mode 100644
index 0000000..850e9a9
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/RotateTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertRotationInRootIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [rotate] modifier. */
+@RunWith(AndroidJUnit4::class)
+class RotateTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun rotation_canApplySingleRotation() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").rotate(90.0f, 0.0f, 0.0f)) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertRotationInRootIsEqualTo(Quaternion(0.70710677f, 0.0f, 0.0f, 0.70710677f))
+    }
+
+    @Test
+    fun rotation_canRotationAcrossTwoAxis() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel").rotate(Vector3(0.0f, 1.0f, 1.0f), 90.0f)
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertRotationInRootIsEqualTo(Quaternion(0.0f, 0.49999997f, 0.49999997f, 0.70710677f))
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ScaleModifierTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ScaleModifierTest.kt
new file mode 100644
index 0000000..8f35ae46
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/ScaleModifierTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.node.SubspaceSemanticsNode
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for scale modifiere. */
+@RunWith(AndroidJUnit4::class)
+class ScaleModifierTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun scale_modifierAppliedToEntity() {
+        composeTestRule.setSubspaceContent { PanelContent("panel", 0.5f) }
+
+        val panelNode = assertSingleNode("panel")
+        assertEquals(0.5f, panelNode.scale)
+    }
+
+    @Test
+    fun negativeScale_throwsException() {
+        assertFailsWith<IllegalArgumentException> {
+            composeTestRule.setSubspaceContent { PanelContent("panel", -0.5f) }
+        }
+    }
+
+    private fun assertSingleNode(testTag: String): SubspaceSemanticsNode {
+        val subspaceNode = composeTestRule.onSubspaceNodeWithTag(testTag).fetchSemanticsNode()
+        assertNotNull(subspaceNode)
+        return subspaceNode
+    }
+
+    @SubspaceComposable
+    @Composable
+    private fun PanelContent(testTag: String, scale: Float? = null) {
+        var modifier = SubspaceModifier.testTag(testTag).size(100.dp)
+
+        scale?.let { modifier = modifier.scale(it) }
+
+        SpatialPanel(modifier = modifier) { Text(text = "Panel") }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SizeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SizeTest.kt
new file mode 100644
index 0000000..0529d73
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SizeTest.kt
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SpatialPanel
+import androidx.xr.compose.subspace.SpatialRow
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.assertDepthIsEqualTo
+import androidx.xr.compose.testing.assertHeightIsEqualTo
+import androidx.xr.compose.testing.assertWidthIsEqualTo
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for size modifiers. */
+@RunWith(AndroidJUnit4::class)
+class SizeTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    @Test
+    fun size_individualModifiers_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel").width(20.dp).height(20.dp).depth(20.dp)
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_combinedModifier_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").size(20.dp)) { Text(text = "Panel") }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_combinedModifier_panelsRespectParentSizeConstraints() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(10.dp)) {
+                SpatialPanel(SubspaceModifier.testTag("panel").size(20.dp)) { Text(text = "Panel") }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(10.dp)
+            .assertHeightIsEqualTo(10.dp)
+            .assertDepthIsEqualTo(10.dp)
+    }
+
+    @Test
+    fun size_individualRequiredModifiers_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(
+                SubspaceModifier.testTag("panel")
+                    .requiredWidth(20.dp)
+                    .requiredHeight(20.dp)
+                    .requiredDepth(20.dp)
+            ) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_combinedRequiredModifier_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialPanel(SubspaceModifier.testTag("panel").requiredSize(20.dp)) {
+                Text(text = "Panel")
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_combinedRequiredModifier_panelsOverrideParentSizeConstraints() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(10.dp)) {
+                SpatialPanel(SubspaceModifier.testTag("panel").requiredSize(20.dp)) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_individualFillModifiers_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(20.dp)) {
+                SpatialPanel(
+                    SubspaceModifier.testTag("panel").fillMaxWidth().fillMaxHeight().fillMaxDepth()
+                ) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_individualFillModifiersWithFraction_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(20.dp)) {
+                SpatialPanel(
+                    SubspaceModifier.testTag("panel")
+                        .fillMaxWidth(0.5f)
+                        .fillMaxHeight(0.5f)
+                        .fillMaxDepth(0.5f)
+                ) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(10.dp)
+            .assertHeightIsEqualTo(10.dp)
+            .assertDepthIsEqualTo(10.dp)
+    }
+
+    @Test
+    fun size_combinedFillModifier_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(20.dp)) {
+                SpatialPanel(SubspaceModifier.testTag("panel").fillMaxSize()) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(20.dp)
+            .assertHeightIsEqualTo(20.dp)
+            .assertDepthIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun size_combinedFillModifierWithFraction_panelsAreSizedCorrectly() {
+        composeTestRule.setSubspaceContent {
+            SpatialRow(SubspaceModifier.size(20.dp)) {
+                SpatialPanel(SubspaceModifier.testTag("panel").fillMaxSize(0.5f)) {
+                    Text(text = "Panel")
+                }
+            }
+        }
+
+        composeTestRule
+            .onSubspaceNodeWithTag("panel")
+            .assertWidthIsEqualTo(10.dp)
+            .assertHeightIsEqualTo(10.dp)
+            .assertDepthIsEqualTo(10.dp)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignmentTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignmentTest.kt
new file mode 100644
index 0000000..a4c5151
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialAlignmentTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.unit.IntVolumeSize
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SpatialAlignment]. */
+@RunWith(AndroidJUnit4::class)
+class SpatialAlignmentTest {
+    @Test
+    fun spatialBiasAlignment_horizontalOffset() {
+        assertThat(SpatialAlignment.CenterLeft.horizontalOffset(width = 100, space = 200))
+            .isEqualTo(-50)
+        assertThat(SpatialAlignment.Center.horizontalOffset(width = 100, space = 200)).isEqualTo(0)
+        assertThat(SpatialAlignment.CenterRight.horizontalOffset(width = 100, space = 200))
+            .isEqualTo(50)
+
+        assertThat(SpatialAlignment.Left.offset(width = 100, space = 200)).isEqualTo(-50)
+        assertThat(SpatialAlignment.CenterHorizontally.offset(width = 100, space = 200))
+            .isEqualTo(0)
+        assertThat(SpatialAlignment.Right.offset(width = 100, space = 200)).isEqualTo(50)
+    }
+
+    @Test
+    fun spatialBiasAlignment_verticalOffset() {
+        assertThat(SpatialAlignment.BottomCenter.verticalOffset(height = 100, space = 200))
+            .isEqualTo(-50)
+        assertThat(SpatialAlignment.Center.verticalOffset(height = 100, space = 200)).isEqualTo(0)
+        assertThat(SpatialAlignment.TopCenter.verticalOffset(height = 100, space = 200))
+            .isEqualTo(50)
+
+        assertThat(SpatialAlignment.Bottom.offset(height = 100, space = 200)).isEqualTo(-50)
+        assertThat(SpatialAlignment.CenterVertically.offset(height = 100, space = 200)).isEqualTo(0)
+        assertThat(SpatialAlignment.Top.offset(height = 100, space = 200)).isEqualTo(50)
+    }
+
+    @Test
+    fun spatialBiasAlignment_depthOffset() {
+        assertThat(SpatialBiasAlignment(0f, 0f, -1f).depthOffset(depth = 100, space = 200))
+            .isEqualTo(-50)
+        assertThat(SpatialBiasAlignment(0f, 0f, 0f).depthOffset(depth = 100, space = 200))
+            .isEqualTo(0)
+        assertThat(SpatialBiasAlignment(0f, 0f, 1f).depthOffset(depth = 100, space = 200))
+            .isEqualTo(50)
+
+        assertThat(SpatialAlignment.Back.offset(depth = 100, space = 200)).isEqualTo(-50)
+        assertThat(SpatialAlignment.CenterDepthwise.offset(depth = 100, space = 200)).isEqualTo(0)
+        assertThat(SpatialAlignment.Front.offset(depth = 100, space = 200)).isEqualTo(50)
+    }
+
+    @Test
+    fun spatialBiasAlignment_position() {
+        val size = IntVolumeSize(100, 100, 100)
+        val space = IntVolumeSize(200, 200, 200)
+        assertThat(SpatialBiasAlignment(-1f, -1f, -1f).position(size, space))
+            .isEqualTo(Vector3(-50f, -50f, -50f))
+        assertThat(SpatialBiasAlignment(0f, 0f, 0f).position(size, space)).isEqualTo(Vector3.Zero)
+        assertThat(SpatialBiasAlignment(1f, 1f, 1f).position(size, space))
+            .isEqualTo(Vector3(50f, 50f, 50f))
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialShapeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialShapeTest.kt
new file mode 100644
index 0000000..0384250c
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SpatialShapeTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.layout
+
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpatialShapeTest {
+    @Test
+    fun roundedCorner_zero() {
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(0))
+                    .computeCornerRadius(maxWidth = 100f, maxHeight = 100f, density = Density(1.0f))
+            )
+            .isEqualTo(0f)
+    }
+
+    @Test
+    fun roundedCorner_dp() {
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(25.dp))
+                    .computeCornerRadius(maxWidth = 100f, maxHeight = 200f, density = Density(1.0f))
+            )
+            .isEqualTo(25f)
+    }
+
+    @Test
+    fun roundedCorner_increasedDensity() {
+        // Returns a larger radius scaled with Density.
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(25.dp))
+                    .computeCornerRadius(maxWidth = 100f, maxHeight = 200f, density = Density(2.0f))
+            )
+            .isEqualTo(50f)
+    }
+
+    @Test
+    fun roundedCorner_extraDp() {
+        // Corner radius is capped at 50% of the smallest side (60). Since the set corner size of
+        // 150
+        // is above this, corner radius becomes equal to the cap.
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(150.dp))
+                    .computeCornerRadius(maxWidth = 120f, maxHeight = 200f, density = Density(1.0f))
+            )
+            .isEqualTo(60f)
+    }
+
+    @Test
+    fun roundedCorner_percent() {
+        // Takes 25% of the smallest side.
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(25))
+                    .computeCornerRadius(maxWidth = 100f, maxHeight = 200f, density = Density(1.0f))
+            )
+            .isEqualTo(25f)
+    }
+
+    @Test
+    fun roundedCorner_extraPercent() {
+        // Anything above 50% has no effect, so this computes 50% of the smallest side.
+        assertThat(
+                SpatialRoundedCornerShape(CornerSize(75))
+                    .computeCornerRadius(maxWidth = 100f, maxHeight = 200f, density = Density(1.0f))
+            )
+            .isEqualTo(50f)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNodeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNodeTest.kt
new file mode 100644
index 0000000..8307bbc
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNodeTest.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.subspace.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.subspace.SubspaceComposable
+import androidx.xr.compose.subspace.layout.CoreContentlessEntity
+import androidx.xr.compose.subspace.layout.SubspaceLayout
+import androidx.xr.compose.subspace.layout.SubspaceModifier
+import androidx.xr.compose.subspace.layout.testTag
+import androidx.xr.compose.testing.SubspaceTestingActivity
+import androidx.xr.compose.testing.createFakeSession
+import androidx.xr.compose.testing.onSubspaceNodeWithTag
+import androidx.xr.compose.testing.setSubspaceContent
+import androidx.xr.scenecore.Entity
+import androidx.xr.scenecore.Session
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Tests for [SubspaceLayoutNode]. */
+@RunWith(AndroidJUnit4::class)
+class SubspaceLayoutNodeTest {
+    @get:Rule val composeTestRule = createAndroidComposeRule<SubspaceTestingActivity>()
+
+    private lateinit var testSession: Session
+
+    @Before
+    fun setUp() {
+        testSession = createFakeSession(composeTestRule.activity)
+    }
+
+    @Test
+    fun subspaceLayoutNode_shouldParentNodesProperly() {
+        val parentEntity = testSession.createEntity("ParentEntity")
+        composeTestRule.setSubspaceContent {
+            EntityLayout(entity = parentEntity) {
+                EntityLayout(
+                    entity = testSession.createEntity("ChildEntity"),
+                    modifier = SubspaceModifier.testTag("Child"),
+                )
+            }
+        }
+
+        assertThat(
+                composeTestRule
+                    .onSubspaceNodeWithTag("Child")
+                    .fetchSemanticsNode()
+                    .coreEntity
+                    ?.entity
+                    ?.getParent()
+            )
+            .isEqualTo(parentEntity)
+    }
+
+    @Test
+    fun subspaceLayoutNode_shouldParentNodesWhenThereIsAGapBetween() {
+        val parentEntity = testSession.createEntity("ParentEntity")
+        composeTestRule.setSubspaceContent {
+            EntityLayout(entity = parentEntity) {
+                EntityLayout(modifier = SubspaceModifier.testTag("Child")) {
+                    EntityLayout(
+                        entity = testSession.createEntity("GrandChildEntity"),
+                        modifier = SubspaceModifier.testTag("GrandChild"),
+                    )
+                }
+            }
+        }
+
+        assertThat(composeTestRule.onSubspaceNodeWithTag("Child").fetchSemanticsNode().coreEntity)
+            .isNull()
+        assertThat(
+                composeTestRule
+                    .onSubspaceNodeWithTag("GrandChild")
+                    .fetchSemanticsNode()
+                    .coreEntity
+                    ?.entity
+                    ?.getParent()
+            )
+            .isEqualTo(parentEntity)
+    }
+
+    @Composable
+    @SubspaceComposable
+    private fun EntityLayout(
+        modifier: SubspaceModifier = SubspaceModifier,
+        entity: Entity? = null,
+        content: @Composable @SubspaceComposable () -> Unit = {},
+    ) {
+        SubspaceLayout(
+            content = content,
+            modifier = modifier,
+            coreEntity = entity?.let { CoreContentlessEntity(it) },
+        ) { _, _ ->
+            layout(0, 0, 0) {}
+        }
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/DpVolumeSizeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/DpVolumeSizeTest.kt
new file mode 100644
index 0000000..0813537
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/DpVolumeSizeTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.scenecore.Dimensions
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DpVolumeSizeTest {
+    @Test
+    fun dpVolumeSize_isCreated() {
+        val dpVolumeSize = DpVolumeSize(0.dp, 0.dp, 0.dp)
+
+        assertNotNull(dpVolumeSize)
+    }
+
+    @Test
+    fun dpVolumeSize_toString_returnsString() {
+        val dpVolumeSize = DpVolumeSize(0.dp, 0.dp, 0.dp)
+
+        val toString = dpVolumeSize.toString()
+
+        assertThat(toString).isEqualTo("DpVolumeSize(width=0.0.dp, height=0.0.dp, depth=0.0.dp)")
+    }
+
+    @Test
+    fun toDimensionsInMeter_returnsCorrectDimensions() {
+        val dpVolumeSize = DpVolumeSize(1151.856f.dp, 1151.856f.dp, 1151.856f.dp)
+
+        val dimensions = dpVolumeSize.toDimensionsInMeters()
+
+        assertThat(dimensions).isEqualTo(Dimensions(1f, 1f, 1f))
+    }
+
+    @Test
+    fun dpVolumeSize_fromMeters_returnsCorrectDpVolumeSize() {
+        val dpVolumeSize = Dimensions(1f, 1f, 1f).toDpVolumeSize()
+
+        assertThat(dpVolumeSize).isEqualTo(DpVolumeSize(1151.856f.dp, 1151.856f.dp, 1151.856f.dp))
+    }
+
+    @Test
+    fun dpVolumeSize_zero_returnsCorrectDpVolumeSize() {
+        val zero = DpVolumeSize.Zero
+
+        assertThat(zero).isEqualTo(DpVolumeSize(0f.dp, 0f.dp, 0f.dp))
+    }
+
+    @Test
+    fun toDimensionsInMeters_andFromMeters_returnsCorrectDpVolumeSize() {
+        val testDpVolumeSize = DpVolumeSize(1111.11f.dp, 1111.11f.dp, 1111.11f.dp)
+
+        val dimensions = testDpVolumeSize.toDimensionsInMeters()
+        val fromMetersDpVolumeSize = dimensions.toDpVolumeSize()
+
+        assertThat(fromMetersDpVolumeSize)
+            .isEqualTo(DpVolumeSize(1111.11f.dp, 1111.11f.dp, 1111.11f.dp))
+    }
+}
+
+/**
+ * Converts this [DpVolumeSize] to a [Dimensions] object in meters.
+ *
+ * @return a [Dimensions] object representing the volume size in meters
+ */
+internal fun DpVolumeSize.toDimensionsInMeters(): Dimensions =
+    Dimensions(width.toMeter().value, height.toMeter().value, depth.toMeter().value)
+
+/**
+ * Creates a [DpVolumeSize] from a [Dimensions] object in meters.
+ *
+ * @param dimensions the [Dimensions] object in meters.
+ * @return a [DpVolumeSize] object representing the same volume size in Dp.
+ */
+internal fun Dimensions.toDpVolumeSize(): DpVolumeSize =
+    DpVolumeSize(Meter(width).toDp(), Meter(height).toDp(), Meter(depth).toDp())
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/IntVolumeSizeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/IntVolumeSizeTest.kt
new file mode 100644
index 0000000..bdc84e2
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/IntVolumeSizeTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.compose.ui.unit.Density
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.scenecore.Dimensions
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class IntVolumeSizeTest {
+    private val UNIT_DENSITY = Density(density = 1.0f, fontScale = 1.0f)
+
+    @Test
+    fun intVolumeSize_toString_returnsString() {
+        val intVolumeSize = IntVolumeSize(0, 0, 0)
+
+        val toString = intVolumeSize.toString()
+
+        assertThat(toString).isEqualTo("IntVolumeSize(width=0, height=0, depth=0)")
+    }
+
+    @Test
+    fun toDimensionsInMeters_returnsCorrectDimensions() {
+        val intVolumeSize = IntVolumeSize(10367, 10367, 10367)
+
+        val dimensions = intVolumeSize.toDimensionsInMeters(UNIT_DENSITY)
+
+        assertThat(dimensions.width).isWithin(0.0003f).of(9.0f)
+        assertThat(dimensions.height).isWithin(0.0003f).of(9.0f)
+        assertThat(dimensions.depth).isWithin(0.0003f).of(9.0f)
+    }
+
+    @Test
+    fun toDimensionsInMeters_returnsCorrectDimensions_doubleDensity() {
+        val intVolumeSize = IntVolumeSize(10367, 10367, 10367)
+        val DOUBLE_DENSITY = Density(density = 2.0f, fontScale = 2.0f)
+
+        val dimensions = intVolumeSize.toDimensionsInMeters(DOUBLE_DENSITY)
+
+        // When pixels are twice as dense, we expect the Meters equivalent to be half.
+        assertThat(dimensions.width).isWithin(0.0002f).of(4.5f)
+        assertThat(dimensions.height).isWithin(0.0002f).of(4.5f)
+        assertThat(dimensions.depth).isWithin(0.0002f).of(4.5f)
+    }
+
+    @Test
+    fun intVolumeSize_zero_returnsCorrectIntVolumeSize() {
+        val intVolumeSize = IntVolumeSize.Zero
+
+        assertThat(intVolumeSize).isEqualTo(IntVolumeSize(0, 0, 0))
+    }
+
+    @Test
+    fun intVolumeSize_fromMeters_returnsCorrectIntVolumeSize() {
+        val dimensions = Dimensions(9.0f, 9.0f, 9.0f)
+
+        val intVolumeSize = dimensions.toIntVolumeSize(UNIT_DENSITY)
+
+        assertThat(intVolumeSize).isEqualTo(IntVolumeSize(10367, 10367, 10367))
+    }
+
+    @Test
+    fun toDimensionsInMeters_andFromMeters_returnsCorrectIntVolumeSize() {
+        val intVolumeSize = IntVolumeSize(10000, 10000, 10000)
+
+        val dimensions = intVolumeSize.toDimensionsInMeters(UNIT_DENSITY)
+        val fromMetersIntVolumeSize = dimensions.toIntVolumeSize(UNIT_DENSITY)
+
+        assertThat(fromMetersIntVolumeSize).isEqualTo(IntVolumeSize(10000, 10000, 10000))
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/MeterTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/MeterTest.kt
new file mode 100644
index 0000000..3a8d696
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/MeterTest.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.compose.unit.Meter.Companion.centimeters
+import androidx.xr.compose.unit.Meter.Companion.meters
+import androidx.xr.compose.unit.Meter.Companion.millimeters
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MeterTest {
+    private val UNIT_DENSITY = Density(density = 1.0f, fontScale = 1.0f)
+
+    @Test
+    fun meter_toDp() {
+        assertThat(1.meters.toDp()).isEqualTo(1151.856f.dp)
+    }
+
+    @Test
+    fun roundToPx_roundsToNearestPixel() {
+        assertThat(1.meters.roundToPx(UNIT_DENSITY)).isEqualTo(1152)
+    }
+
+    @Test
+    fun roundToPx_roundsToNearestPixel_doubleDensity() {
+        val DOUBLE_DENSITY = Density(density = 2.0f, fontScale = 2.0f)
+
+        // Twice the density, twice the pixels.
+        assertThat(1.meters.roundToPx(DOUBLE_DENSITY)).isEqualTo(2304)
+    }
+
+    @Test
+    fun dp_toMeter() {
+        assertThat(10.dp.toMeter()).isEqualTo(Meter(0.008681641f))
+        assertThat(Dp.Infinity.toMeter()).isEqualTo(Meter.Infinity)
+        assertThat(Dp.Unspecified.toMeter()).isEqualTo(Meter.NaN)
+
+        // Reference: "Hairline elements take up no space, but will draw a single pixel, independent
+        // of the device's resolution and density."
+        // https://developer.android.com/reference/kotlin/androidx/compose/ui/unit/Dp#Hairline()
+        //
+        // Currently, hairlines are exactly 0 meters.
+        assertThat(Dp.Hairline.toMeter()).isEqualTo(Meter(0.0f))
+    }
+
+    @Test
+    fun meter_toMm() {
+        assertThat(22.meters.toMm()).isEqualTo(22000f)
+    }
+
+    @Test
+    fun meter_toCm() {
+        assertThat(11.meters.toCm()).isEqualTo(1100f)
+    }
+
+    @Test
+    fun meter_toM() {
+        assertThat(11.meters.toM()).isEqualTo(11f)
+    }
+
+    @Test
+    fun meter_toPx() {
+        assertThat(5.meters.toPx(UNIT_DENSITY)).isEqualTo(5759.28f)
+    }
+
+    @Test
+    fun meter_roundToPx() {
+        assertThat(5.meters.roundToPx(UNIT_DENSITY)).isEqualTo(5759)
+    }
+
+    @Test
+    fun meter_todp() {
+        assertThat(5.meters.toDp()).isEqualTo(5759.28f.dp)
+    }
+
+    @Test
+    fun meter_isSpecified() {
+        assertThat(5.meters.isSpecified).isTrue()
+        assertThat(Meter.NaN.isSpecified).isFalse()
+    }
+
+    @Test
+    fun meter_isFinite() {
+        assertThat(5.meters.isFinite).isTrue()
+        assertThat(Meter.Infinity.isFinite).isFalse()
+    }
+
+    @Test
+    fun meter_fromIntMilli() {
+        val test: Meter = 22.millimeters
+        assertThat(test).isEqualTo(Meter(0.022000002f))
+    }
+
+    @Test
+    fun meter_funFloatMilli() {
+        val test = 22f.millimeters
+        assertThat(test).isEqualTo(Meter(0.022000002f))
+    }
+
+    @Test
+    fun meter_funDoubleMilli() {
+        val test = 22.0.millimeters
+        assertThat(test).isEqualTo(Meter(0.022000002f))
+    }
+
+    @Test
+    fun meter_funIntCm() {
+        val test = 22.centimeters
+        assertThat(test).isEqualTo(Meter(0.22f))
+    }
+
+    @Test
+    fun meter_funFloatCm() {
+        val test = 22f.centimeters
+        assertThat(test).isEqualTo(Meter(0.22f))
+    }
+
+    @Test
+    fun meter_funDoubleCm() {
+        val test = 22.0.centimeters
+        assertThat(test).isEqualTo(Meter(0.22f))
+    }
+
+    @Test
+    fun meter_funIntM() {
+        val test = 22.meters
+        assertThat(test).isEqualTo(Meter(22f))
+    }
+
+    @Test
+    fun meter_funFloatM() {
+        val test = 22f.meters
+        assertThat(test).isEqualTo(Meter(22f))
+    }
+
+    @Test
+    fun meter_funDoubleM() {
+        val test = 22.0.meters
+        assertThat(test).isEqualTo(Meter(22f))
+    }
+
+    @Test
+    fun intTimesMeters() {
+        val test: Meter = 22.meters
+        assertThat(11 * test).isEqualTo(242.meters)
+    }
+
+    @Test
+    fun floatTimesMeters() {
+        val test: Meter = 22.meters
+        assertThat(11f * test).isEqualTo(242.meters)
+    }
+
+    @Test
+    fun doubleTimesMeters() {
+        val test: Meter = 22.meters
+        assertThat(11.0 * test).isEqualTo(242.meters)
+    }
+
+    @Test
+    fun intDivMeters() {
+        val test: Meter = 22.meters
+        assertThat(test / 11).isEqualTo(2.meters)
+    }
+
+    @Test
+    fun floatDivMeters() {
+        val test: Meter = 22.meters
+        assertThat(test / 11f).isEqualTo(2.meters)
+    }
+
+    @Test
+    fun doubleDivMeters() {
+        val test: Meter = 22.meters
+        assertThat(test / 11.0).isEqualTo(2.meters)
+    }
+
+    @Test
+    fun px_toMeter_toPx() {
+        val density = Density(2.789f)
+        assertThat(Meter.fromPixel(28.9f, density).toPx(density)).isWithin(1.0e-5f).of(28.9f)
+    }
+}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/VolumeConstraintsTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/VolumeConstraintsTest.kt
new file mode 100644
index 0000000..4fe5493
--- /dev/null
+++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/unit/VolumeConstraintsTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.unit
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class VolumeConstraintsTest {
+
+    @Test
+    fun volumeConstraints_hasBoundedWidth_returnsTrue() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, 0)
+
+        val hasBoundedWidth = volumeConstraints.hasBoundedWidth
+
+        assertThat(hasBoundedWidth).isTrue()
+    }
+
+    @Test
+    fun volumeConstraints_hasBoundedWidth_returnsFalse() {
+        val volumeConstraints = VolumeConstraints(0, VolumeConstraints.INFINITY, 0, 0, 0, 0)
+
+        val hasBoundedWidth = volumeConstraints.hasBoundedWidth
+
+        assertThat(hasBoundedWidth).isFalse()
+    }
+
+    @Test
+    fun volumeConstraints_hasBoundedHeight_returnsTrue() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, 0)
+
+        val hasBoundedHeight = volumeConstraints.hasBoundedHeight
+
+        assertThat(hasBoundedHeight).isTrue()
+    }
+
+    @Test
+    fun volumeConstraints_hasBoundedHeight_returnsFalse() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, VolumeConstraints.INFINITY, 0, 0)
+
+        val hasBoundedHeight = volumeConstraints.hasBoundedHeight
+
+        assertThat(hasBoundedHeight).isFalse()
+    }
+
+    @Test
+    fun volumeConstraints_hasBoundedDepth_returnsTrue() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, 0)
+
+        val hasBoundedDepth = volumeConstraints.hasBoundedDepth
+
+        assertThat(hasBoundedDepth).isTrue()
+    }
+
+    @Test
+    fun volumeConstraints_hasBoundedDepth_returnsFalse() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, VolumeConstraints.INFINITY)
+
+        val hasBoundedDepth = volumeConstraints.hasBoundedDepth
+
+        assertThat(hasBoundedDepth).isFalse()
+    }
+
+    @Test
+    fun volumeConstraints_toString_returnsString() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, 0)
+
+        val toString = volumeConstraints.toString()
+
+        assertThat(toString).isEqualTo("width: 0-0, height: 0-0, depth=0-0")
+    }
+
+    @Test
+    fun volumeConstraints_copy_returnsCorrectVolumeConstraints() {
+        val volumeConstraints = VolumeConstraints(0, 0, 0, 0, 0, 0)
+
+        val copyVolumeConstraints = volumeConstraints.copy()
+
+        assertThat(copyVolumeConstraints).isEqualTo(volumeConstraints)
+    }
+
+    @Test
+    fun volumeConstraints_infinity_returnsCorrectVolumeConstraints() {
+        val infinity = VolumeConstraints.INFINITY
+
+        assertThat(infinity).isEqualTo(Int.MAX_VALUE)
+    }
+
+    @Test
+    fun volumeConstraints_constrain_returnsCorrectVolumeConstraints() {
+        val volumeConstraints = VolumeConstraints(1, 2, 1, 2, 1, 2)
+        val otherVolumeConstraints = VolumeConstraints(4, 5, 4, 5, 4, 5)
+
+        val constrainedVolumeConstraints = volumeConstraints.constrain(otherVolumeConstraints)
+
+        assertThat(constrainedVolumeConstraints).isEqualTo(VolumeConstraints(2, 2, 2, 2, 2, 2))
+    }
+
+    @Test
+    fun volumeConstraints_constrainWidth_returnsCorrectWidth() {
+        val volumeConstraints = VolumeConstraints(1, 2, 1, 2, 1, 2)
+
+        val constrainedWidth = volumeConstraints.constrainWidth(3)
+
+        assertThat(constrainedWidth).isEqualTo(2)
+    }
+
+    @Test
+    fun volumeConstraints_constrainHeight_returnsCorrectHeight() {
+        val volumeConstraints = VolumeConstraints(1, 2, 1, 2, 1, 2)
+
+        val constrainedHeight = volumeConstraints.constrainHeight(3)
+
+        assertThat(constrainedHeight).isEqualTo(2)
+    }
+
+    @Test
+    fun volumeConstraints_constrainDepth_returnsCorrectDepth() {
+        val volumeConstraints = VolumeConstraints(1, 2, 1, 2, 1, 2)
+
+        val constrainedDepth = volumeConstraints.constrainDepth(3)
+
+        assertThat(constrainedDepth).isEqualTo(2)
+    }
+
+    @Test
+    fun volumeConstraints_offset_returnsCorrectVolumeConstraints() {
+        val volumeConstraints = VolumeConstraints(1, 2, 1, 2, 1, 2)
+
+        val offsetVolumeConstraints = volumeConstraints.offset(1, 2, 2)
+
+        assertThat(offsetVolumeConstraints).isEqualTo(VolumeConstraints(2, 3, 3, 4, 3, 4))
+    }
+}
diff --git a/xr/compose/material3/OWNERS b/xr/compose/material3/OWNERS
new file mode 100644
index 0000000..533ffc7
--- /dev/null
+++ b/xr/compose/material3/OWNERS
@@ -0,0 +1,4 @@
+# Bug component: 1524104
[email protected]
[email protected]
[email protected]
diff --git a/xr/compose/material3/integration-tests/testapp/build.gradle b/xr/compose/material3/integration-tests/testapp/build.gradle
new file mode 100644
index 0000000..1033dd7
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/build.gradle
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    implementation(libs.kotlinStdlib)
+
+    implementation(project(":appcompat:appcompat"))
+    implementation(project(":activity:activity-compose"))
+    implementation(project(":compose:foundation:foundation"))
+    implementation(project(":compose:foundation:foundation-layout"))
+    implementation(project(":compose:material3:material3"))
+    implementation("androidx.compose.material:material-icons-core:1.6.8")
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:ui:ui"))
+    implementation(project(":compose:ui:ui-tooling-preview"))
+
+    // Adaptive (CAMAL) dependencies
+    implementation("androidx.compose.material3.adaptive:adaptive:1.1.0-alpha03")
+    implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0-alpha03")
+    implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0-alpha03")
+    implementation(project(":compose:material3:material3-adaptive-navigation-suite"))
+
+    // XR Adaptive integration library
+    implementation(project(":xr:compose:material3:material3"))
+    implementation(project(":xr:compose:compose"))
+    implementation(project(":xr:scenecore:scenecore"))
+
+    // TODO(b/374796755): Write unit-tests for this test app
+}
+
+android {
+    compileSdk 35
+    defaultConfig {
+        minSdk 32
+    }
+    namespace "androidx.xr.compose.material3.integration.testapp"
+}
+
+androidx {
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/AndroidManifest.xml b/xr/compose/material3/integration-tests/testapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e486a56
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+<!--
+  ~ Copyright (C) 2024 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
+  -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.SCENE_UNDERSTANDING" />
+
+    <application
+        android:label="XR Compose Adaptive Test App"
+        android:supportsRtl="true" tools:ignore="GoogleAppIndexingWarning"
+        android:allowBackup="false"
+        android:enableOnBackInvokedCallback="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:configChanges="uiMode"
+            android:exported="true"
+            android:theme="@android:style/Theme.Material.Light.NoActionBar">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/Destination.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/Destination.kt
new file mode 100644
index 0000000..3f11715
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/Destination.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+internal enum class Destination {
+    Dialog,
+}
+
+internal val Destination.label: String
+    get() =
+        when (this) {
+            Destination.Dialog -> "Dialog"
+        }
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPane.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPane.kt
new file mode 100644
index 0000000..213f2b3
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPane.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Dialog
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+internal fun DetailPane(navigator: ThreePaneScaffoldNavigator<Destination>) {
+    val destination = navigator.currentDestination?.contentKey ?: return
+    val coroutineScope = rememberCoroutineScope()
+    Scaffold(
+        topBar = { TopAppBar(title = { Text("XR Compose Adaptive: ${destination.label}") }) },
+    ) { innerPadding ->
+        Surface(Modifier.fillMaxSize().padding(innerPadding)) {
+            when (destination) {
+                Destination.Dialog -> DetailPaneDialog()
+            }
+        }
+    }
+    BackHandler { coroutineScope.launch { navigator.navigateBack() } }
+}
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPaneDialog.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPaneDialog.kt
new file mode 100644
index 0000000..f34fe02
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/DetailPaneDialog.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.xr.compose.spatial.SpatialDialog
+
+@Composable
+internal fun DetailPaneDialog() {
+    Column(
+        verticalArrangement = Arrangement.spacedBy(16.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        SimpleDialog()
+        MaterialAlertDialog()
+        XrElevatedDialog()
+    }
+}
+
+@Composable
+private fun SimpleDialog() {
+    DialogWithShowButton("Compose UI Dialog") { showDialog ->
+        Dialog(onDismissRequest = { showDialog.value = false }) {
+            Card(Modifier.fillMaxWidth(0.8f).fillMaxHeight(0.5f)) {
+                Text("This is a simple Dialog with a Material Card inside.")
+            }
+        }
+    }
+}
+
+@Composable
+private fun MaterialAlertDialog() {
+    val context = LocalContext.current
+    DialogWithShowButton("Material Alert Dialog") { showDialog ->
+        AlertDialog(
+            onDismissRequest = { showDialog.value = false },
+            text = { Text("This is a Material AlertDialog") },
+            confirmButton = {
+                Button(
+                    onClick = {
+                        Toast.makeText(context, "Confirm button clicked", Toast.LENGTH_LONG).show()
+                        showDialog.value = false
+                    },
+                ) {
+                    Text("Confirm")
+                }
+            },
+        )
+    }
+}
+
+@Composable
+private fun XrElevatedDialog() {
+    DialogWithShowButton("XR ElevatedDialog") { showDialog ->
+        SpatialDialog(onDismissRequest = { showDialog.value = false }) {
+            Card(Modifier.fillMaxWidth(0.8f).fillMaxHeight(0.5f)) {
+                Text("This is an XR ElevatedDialog with a Material Card inside.")
+            }
+        }
+    }
+}
+
+@Composable
+private fun DialogWithShowButton(
+    text: String,
+    content: @Composable (MutableState<Boolean>) -> Unit,
+) {
+    val showDialog = remember { mutableStateOf(false) }
+    Button(onClick = { showDialog.value = true }) { Text(text) }
+    if (showDialog.value) {
+        content(showDialog)
+    }
+}
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/ListPane.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/ListPane.kt
new file mode 100644
index 0000000..daa0caa
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/ListPane.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+internal fun ListPane(navigator: ThreePaneScaffoldNavigator<Destination>) {
+    val coroutineScope = rememberCoroutineScope()
+    Scaffold(
+        topBar = { TopAppBar(title = { Text("XR Compose Adaptive Sample") }) },
+    ) { innerPadding ->
+        LazyColumn(Modifier.fillMaxSize().padding(innerPadding)) {
+            items(Destination.values()) { destination ->
+                Surface(
+                    onClick = {
+                        coroutineScope.launch {
+                            navigator.navigateTo(
+                                ListDetailPaneScaffoldRole.Detail,
+                                contentKey = destination,
+                            )
+                        }
+                    },
+                ) {
+                    ListItem(headlineContent = { Text(destination.label) })
+                }
+            }
+        }
+    }
+}
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/MainActivity.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/MainActivity.kt
new file mode 100644
index 0000000..44b8d83
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/MainActivity.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 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.
+ */
+
+// TODO(b/289518597): Remove this SuppressLint
+@file:SuppressLint("NullAnnotationGroup")
+@file:OptIn(
+    ExperimentalMaterial3AdaptiveApi::class,
+    ExperimentalMaterial3Api::class,
+    ExperimentalMaterial3XrApi::class
+)
+
+package androidx.xr.compose.material3.integration.testapp
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.xr.compose.material3.EnableXrComponentOverrides
+import androidx.xr.compose.material3.ExperimentalMaterial3XrApi
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+        setContent { EnableXrComponentOverrides { Content() } }
+    }
+}
+
+private val navSuiteType = mutableStateOf(NavigationSuiteType.NavigationRail)
+
+@Composable
+private fun Content() {
+    var navSuiteSelectedItem by remember { mutableStateOf(NavSuiteItem.HOME) }
+
+    NavigationSuiteScaffold(
+        navigationSuiteItems = {
+            NavSuiteItem.values().forEach { item ->
+                item(
+                    selected = navSuiteSelectedItem == item,
+                    onClick = { navSuiteSelectedItem = item },
+                    icon = { Icon(item.icon, contentDescription = item.label) },
+                    label = { Text(item.label) },
+                )
+            }
+        },
+        layoutType = navSuiteType.value
+    ) {
+        when (navSuiteSelectedItem) {
+            NavSuiteItem.HOME -> {
+                Home()
+            }
+            NavSuiteItem.SETTINGS -> {
+                XrSettingsPane(navSuiteType)
+            }
+        }
+    }
+}
+
+@Composable
+private fun Home() {
+    val navigator: ThreePaneScaffoldNavigator<Destination> =
+        rememberListDetailPaneScaffoldNavigator(
+            initialDestinationHistory =
+                listOf(ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List))
+        )
+    ListDetailPaneScaffold(
+        directive = navigator.scaffoldDirective,
+        value = navigator.scaffoldValue,
+        listPane = { AnimatedPane { ListPane(navigator) } },
+        detailPane = { AnimatedPane { DetailPane(navigator) } },
+    )
+}
+
+private const val TAG = "MainActivity"
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/NavSuiteItem.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/NavSuiteItem.kt
new file mode 100644
index 0000000..d40f924
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/NavSuiteItem.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.ui.graphics.vector.ImageVector
+
+enum class NavSuiteItem {
+    HOME,
+    SETTINGS,
+}
+
+val NavSuiteItem.label: String
+    get() =
+        when (this) {
+            NavSuiteItem.HOME -> "Home"
+            NavSuiteItem.SETTINGS -> "Settings"
+        }
+
+val NavSuiteItem.icon: ImageVector
+    get() =
+        when (this) {
+            NavSuiteItem.HOME -> Icons.Default.Home
+            NavSuiteItem.SETTINGS -> Icons.Default.Settings
+        }
diff --git a/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/XrSettingsPane.kt b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/XrSettingsPane.kt
new file mode 100644
index 0000000..e1faf48
--- /dev/null
+++ b/xr/compose/material3/integration-tests/testapp/src/main/java/androidx/xr/compose/material3/integration/testapp/XrSettingsPane.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3.integration.testapp
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.platform.LocalSession
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+
+@Composable
+internal fun XrSettingsPane(navSuiteType: MutableState<NavigationSuiteType>) {
+    Scaffold { innerPadding ->
+        Column(
+            modifier = Modifier.padding(innerPadding),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            ListItem(headlineContent = { XrModeButton() })
+            ListItem(headlineContent = { NavigationSuiteTypeButton(navSuiteType) })
+        }
+    }
+}
+
+@Composable
+private fun XrModeButton() {
+    val session = LocalSession.current
+    val isDeviceXr = session != null
+    val isFullSpaceMode = LocalSpatialCapabilities.current.isSpatialUiEnabled
+
+    Button(
+        modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp),
+        enabled = isDeviceXr,
+        onClick = {
+            if (isFullSpaceMode) {
+                session?.requestHomeSpaceMode()
+            } else {
+                session?.requestFullSpaceMode()
+            }
+        }
+    ) {
+        Text(
+            text = if (isDeviceXr) "Toggle FullSpace/HomeSpace Mode" else "XR unsupported",
+            style = MaterialTheme.typography.bodyLarge,
+        )
+    }
+}
+
+@Composable
+private fun NavigationSuiteTypeButton(navSuiteType: MutableState<NavigationSuiteType>) {
+    Button(
+        modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp),
+        onClick = {
+            if (navSuiteType.value == NavigationSuiteType.NavigationRail) {
+                navSuiteType.value = NavigationSuiteType.NavigationBar
+            } else {
+                navSuiteType.value = NavigationSuiteType.NavigationRail
+            }
+        }
+    ) {
+        Text(text = "Toggle NavRail/NavBar", style = MaterialTheme.typography.bodyLarge)
+    }
+}
diff --git a/xr/compose/material3/material3/api/current.txt b/xr/compose/material3/material3/api/current.txt
new file mode 100644
index 0000000..1074b90
--- /dev/null
+++ b/xr/compose/material3/material3/api/current.txt
@@ -0,0 +1,40 @@
+// Signature format: 4.0
+package androidx.xr.compose.material3 {
+
+  public final class EnableXrComponentOverridesKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void EnableXrComponentOverrides(optional androidx.xr.compose.material3.XrComponentOverrideEnabler overrideEnabler, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @kotlin.RequiresOptIn(message="This material XR API is experimental and is likely to change or to be removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalMaterial3XrApi {
+  }
+
+  public final class NavigationBarKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void NavigationBar(optional androidx.compose.ui.Modifier modifier, optional long containerColor, optional long contentColor, optional float tonalElevation, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+  }
+
+  public final class NavigationRailKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void NavigationRail(optional androidx.compose.ui.Modifier modifier, optional long containerColor, optional long contentColor, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit>? header, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @kotlin.jvm.JvmInline public final value class XrComponentOverride {
+    field public static final androidx.xr.compose.material3.XrComponentOverride.Companion Companion;
+  }
+
+  public static final class XrComponentOverride.Companion {
+    method @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String getNavigationBar();
+    method @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String getNavigationRail();
+    property @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String NavigationBar;
+    property @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String NavigationRail;
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public interface XrComponentOverrideEnabler {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public boolean shouldOverrideComponent(androidx.xr.compose.material3.XrComponentOverrideEnablerContext, String component);
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public sealed interface XrComponentOverrideEnablerContext {
+    method @androidx.compose.runtime.Composable public boolean isSpatializationEnabled();
+    property @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public abstract boolean isSpatializationEnabled;
+  }
+
+}
+
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/compose/material3/material3/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/compose/material3/material3/api/res-current.txt
diff --git a/xr/compose/material3/material3/api/restricted_current.txt b/xr/compose/material3/material3/api/restricted_current.txt
new file mode 100644
index 0000000..1074b90
--- /dev/null
+++ b/xr/compose/material3/material3/api/restricted_current.txt
@@ -0,0 +1,40 @@
+// Signature format: 4.0
+package androidx.xr.compose.material3 {
+
+  public final class EnableXrComponentOverridesKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void EnableXrComponentOverrides(optional androidx.xr.compose.material3.XrComponentOverrideEnabler overrideEnabler, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @kotlin.RequiresOptIn(message="This material XR API is experimental and is likely to change or to be removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalMaterial3XrApi {
+  }
+
+  public final class NavigationBarKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void NavigationBar(optional androidx.compose.ui.Modifier modifier, optional long containerColor, optional long contentColor, optional float tonalElevation, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+  }
+
+  public final class NavigationRailKt {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public static void NavigationRail(optional androidx.compose.ui.Modifier modifier, optional long containerColor, optional long contentColor, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit>? header, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @kotlin.jvm.JvmInline public final value class XrComponentOverride {
+    field public static final androidx.xr.compose.material3.XrComponentOverride.Companion Companion;
+  }
+
+  public static final class XrComponentOverride.Companion {
+    method @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String getNavigationBar();
+    method @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String getNavigationRail();
+    property @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String NavigationBar;
+    property @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public String NavigationRail;
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public interface XrComponentOverrideEnabler {
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public boolean shouldOverrideComponent(androidx.xr.compose.material3.XrComponentOverrideEnablerContext, String component);
+  }
+
+  @SuppressCompatibility @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public sealed interface XrComponentOverrideEnablerContext {
+    method @androidx.compose.runtime.Composable public boolean isSpatializationEnabled();
+    property @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.xr.compose.material3.ExperimentalMaterial3XrApi public abstract boolean isSpatializationEnabled;
+  }
+
+}
+
diff --git a/xr/compose/material3/material3/build.gradle b/xr/compose/material3/material3/build.gradle
new file mode 100644
index 0000000..fdf7bf0
--- /dev/null
+++ b/xr/compose/material3/material3/build.gradle
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+import androidx.build.Publish
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    // Add dependencies here
+    implementation(project(":compose:material3:adaptive:adaptive"))
+    implementation(project(":compose:material3:material3"))
+    implementation(project(":compose:material3:material3-adaptive-navigation-suite"))
+
+    implementation(project(":xr:compose:compose"))
+}
+
+android {
+    compileSdk 35
+    defaultConfig {
+        minSdk 32
+    }
+    namespace "androidx.xr.compose.material3"
+}
+
+androidx {
+    name = "Compose Material3 XR"
+    description = "Compose Material3 components for XR"
+    type = LibraryType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
+    inceptionYear = "2024"
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/EnableXrComponentOverrides.kt b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/EnableXrComponentOverrides.kt
new file mode 100644
index 0000000..898a521
--- /dev/null
+++ b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/EnableXrComponentOverrides.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalNavigationBarComponentOverride
+import androidx.compose.material3.LocalNavigationRailComponentOverride
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ProvidedValue
+import androidx.xr.compose.platform.LocalSpatialCapabilities
+
+/**
+ * Clients can wrap their Compose hierarchy in this function to dynamically enable XR components
+ * when in the proper environment.
+ *
+ * The [overrideEnabler] param determines whether each component will use an XR version.
+ */
+@ExperimentalMaterial3XrApi
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+public fun EnableXrComponentOverrides(
+    overrideEnabler: XrComponentOverrideEnabler = DefaultXrComponentOverrideEnabler,
+    content: @Composable () -> Unit,
+) {
+    val context = XrComponentOverrideEnablerContextImpl
+
+    // Override CompositionLocals for all ComponentOverrides, as specified by the provided enabler.
+    val componentOverrides =
+        buildList<ProvidedValue<*>> {
+            with(overrideEnabler) {
+                if (context.shouldOverrideComponent(XrComponentOverride.NavigationRail)) {
+                    add(
+                        LocalNavigationRailComponentOverride provides
+                            XrNavigationRailComponentOverride
+                    )
+                }
+                if (context.shouldOverrideComponent(XrComponentOverride.NavigationBar)) {
+                    add(
+                        LocalNavigationBarComponentOverride provides
+                            XrNavigationBarComponentOverride
+                    )
+                }
+            }
+        }
+    CompositionLocalProvider(values = componentOverrides.toTypedArray(), content = content)
+}
+
+/** Interface that a client can provide to enable/disable XR overrides on a per-component basis. */
+@ExperimentalMaterial3XrApi
+public interface XrComponentOverrideEnabler {
+    /**
+     * Used to determine whether the XR version of a given component should be used.
+     *
+     * @param component the component that may or may not use the XR version
+     * @return whether the XR version of this component should be used
+     */
+    @Composable
+    @ExperimentalMaterial3XrApi
+    public fun XrComponentOverrideEnablerContext.shouldOverrideComponent(
+        component: XrComponentOverride
+    ): Boolean
+}
+
+/** Information about the current XR environment. */
+@ExperimentalMaterial3XrApi
+public sealed interface XrComponentOverrideEnablerContext {
+    /** Whether the user is in an environment that supports XR spatialization. */
+    @ExperimentalMaterial3XrApi @get:Composable public val isSpatializationEnabled: Boolean
+}
+
+/** The set of Material Components that can be overridden on XR. */
+@ExperimentalMaterial3XrApi
+@JvmInline
+public value class XrComponentOverride private constructor(private val name: String) {
+    public companion object {
+        /** Material3 NavigationRail. */
+        @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+        @get:ExperimentalMaterial3XrApi
+        @ExperimentalMaterial3XrApi
+        public val NavigationRail: XrComponentOverride = XrComponentOverride("NavigationRail")
+
+        /** Material3 NavigationBar. */
+        @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+        @get:ExperimentalMaterial3XrApi
+        @ExperimentalMaterial3XrApi
+        public val NavigationBar: XrComponentOverride = XrComponentOverride("NavigationBar")
+    }
+}
+
+@ExperimentalMaterial3XrApi
+private object XrComponentOverrideEnablerContextImpl : XrComponentOverrideEnablerContext {
+    override val isSpatializationEnabled: Boolean
+        @Composable get() = LocalSpatialCapabilities.current.isSpatialUiEnabled
+}
+
+@OptIn(ExperimentalMaterial3XrApi::class)
+private object DefaultXrComponentOverrideEnabler : XrComponentOverrideEnabler {
+    @Composable
+    override fun XrComponentOverrideEnablerContext.shouldOverrideComponent(
+        component: XrComponentOverride
+    ): Boolean = isSpatializationEnabled
+}
diff --git a/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/ExperimentalMaterial3XrApi.kt b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/ExperimentalMaterial3XrApi.kt
new file mode 100644
index 0000000..01e0eae
--- /dev/null
+++ b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/ExperimentalMaterial3XrApi.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3
+
+@RequiresOptIn(
+    "This material XR API is experimental and is likely to change or to be removed in the future."
+)
+@Retention(AnnotationRetention.BINARY)
+public annotation class ExperimentalMaterial3XrApi
diff --git a/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationBar.kt b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationBar.kt
new file mode 100644
index 0000000..a0de7c1
--- /dev/null
+++ b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationBar.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.NavigationBarComponentOverride
+import androidx.compose.material3.NavigationBarComponentOverrideContext
+import androidx.compose.material3.NavigationBarDefaults
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.spatial.EdgeOffset
+import androidx.xr.compose.spatial.Orbiter
+import androidx.xr.compose.spatial.OrbiterEdge
+
+/**
+ * <a href="https://m3.material.io/components/navigation-bar/overview" class="external"
+ * target="_blank">Material Design bottom navigation bar</a>.
+ *
+ * XR-specific Navigation bar that shows a Navigation bar in a bottom-aligned [Orbiter].
+ *
+ * Navigation bars offer a persistent and convenient way to switch between primary destinations in
+ * an app.
+ *
+ * [NavigationBar] should contain three to five [NavigationBarItem]s, each representing a singular
+ * destination.
+ *
+ * See [NavigationBarItem] for configuration specific to each item, and not the overall
+ * [NavigationBar] component.
+ *
+ * @param modifier the [Modifier] to be applied to this navigation bar
+ * @param containerColor the color used for the background of this navigation bar. Use
+ *   [Color.Transparent] to have no color.
+ * @param contentColor the preferred color for content inside this navigation bar. Defaults to
+ *   either the matching content color for [containerColor], or to the current [LocalContentColor]
+ *   if [containerColor] is not a color from the theme.
+ * @param tonalElevation when [containerColor] is [ColorScheme.surface], a translucent primary color
+ *   overlay is applied on top of the container. A higher tonal elevation value will result in a
+ *   darker color in light theme and lighter color in dark theme. See also: [Surface].
+ * @param content the content of this navigation bar, typically 3-5 [NavigationBarItem]s
+ */
+// TODO(brandonjiang): Link to XR-specific NavBar image asset when available
+// TODO(brandonjiang): Add a @sample tag and create a new sample project for XR.
+@ExperimentalMaterial3XrApi
+@Composable
+public fun NavigationBar(
+    modifier: Modifier = Modifier,
+    containerColor: Color = NavigationBarDefaults.containerColor,
+    contentColor: Color = contentColorFor(containerColor),
+    tonalElevation: Dp = NavigationBarDefaults.Elevation,
+    content: @Composable RowScope.() -> Unit
+) {
+    Orbiter(position = OrbiterEdge.Bottom, offset = XrNavigationBarTokens.OrbiterEdgeOffset) {
+        Surface(
+            shape = CircleShape,
+            color = containerColor,
+            contentColor = contentColor,
+            tonalElevation = tonalElevation,
+            modifier = modifier
+        ) {
+            Row(
+                // XR-changed: Original NavigationBar uses fillMaxWidth() and windowInsets,
+                // which do not produce the desired result in XR.
+                modifier =
+                    Modifier.width(IntrinsicSize.Min)
+                        .heightIn(min = XrNavigationBarTokens.ContainerHeight)
+                        .padding(horizontal = XrNavigationBarTokens.HorizontalPadding)
+                        .selectableGroup(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.Center,
+                content = content
+            )
+        }
+    }
+}
+
+private object XrNavigationBarTokens {
+    /** The [EdgeOffset] for NavigationBar Orbiters in Full Space Mode (FSM). */
+    val OrbiterEdgeOffset
+        @Composable get() = EdgeOffset.inner(24.dp)
+
+    val HorizontalPadding = 8.dp
+
+    val ContainerHeight = 80.0.dp
+}
+
+/** [NavigationBarComponentOverride] that uses the XR-specific [NavigationBar]. */
+@ExperimentalMaterial3XrApi
+@OptIn(ExperimentalMaterial3Api::class)
+internal object XrNavigationBarComponentOverride : NavigationBarComponentOverride {
+    @Composable
+    override fun NavigationBarComponentOverrideContext.NavigationBar() {
+        NavigationBar(
+            modifier = modifier,
+            containerColor = containerColor,
+            contentColor = contentColor,
+            tonalElevation = tonalElevation,
+            content = content,
+        )
+    }
+}
diff --git a/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationRail.kt b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationRail.kt
new file mode 100644
index 0000000..931617c
--- /dev/null
+++ b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/NavigationRail.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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 androidx.xr.compose.material3
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.NavigationRailComponentOverride
+import androidx.compose.material3.NavigationRailComponentOverrideContext
+import androidx.compose.material3.NavigationRailDefaults
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.xr.compose.spatial.EdgeOffset
+import androidx.xr.compose.spatial.Orbiter
+import androidx.xr.compose.spatial.OrbiterEdge
+
+/**
+ * <a href="https://m3.material.io/components/navigation-rail/overview" class="external"
+ * target="_blank">Material Design bottom navigation rail</a>.
+ *
+ * XR-specific Navigation rail that shows a Navigation rail in a start-aligned [Orbiter].
+ *
+ * Navigation rails provide access to primary destinations in apps when using tablet and desktop
+ * screens.
+ *
+ * The navigation rail should be used to display three to seven app destinations and, optionally, a
+ * [FloatingActionButton] or a logo header. Each destination is typically represented by an icon and
+ * an optional text label.
+ *
+ * [NavigationRail] should contain multiple [NavigationRailItem]s, each representing a singular
+ * destination.
+ *
+ * See [NavigationRailItem] for configuration specific to each item, and not the overall
+ * NavigationRail component.
+ *
+ * @param modifier the [Modifier] to be applied to this navigation rail
+ * @param containerColor the color used for the background of this navigation rail. Use
+ *   [Color.Transparent] to have no color.
+ * @param contentColor the preferred color for content inside this navigation rail. Defaults to
+ *   either the matching content color for [containerColor], or to the current [LocalContentColor]
+ *   if [containerColor] is not a color from the theme.
+ * @param header optional header that may hold a [FloatingActionButton] or a logo
+ * @param content the content of this navigation rail, typically 3-7 [NavigationRailItem]s
+ */
+// TODO(kmost): Link to XR-specific NavRail image asset when available
+// TODO(kmost): Add a @sample tag and create a new sample project for XR.
+@ExperimentalMaterial3XrApi
+@Composable
+public fun NavigationRail(
+    modifier: Modifier = Modifier,
+    containerColor: Color = NavigationRailDefaults.ContainerColor,
+    contentColor: Color = contentColorFor(containerColor),
+    header: @Composable (ColumnScope.() -> Unit)? = null,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    Orbiter(
+        position = OrbiterEdge.Start,
+        alignment = Alignment.CenterVertically,
+        offset = XrNavigationRailTokens.OrbiterEdgeOffset,
+    ) {
+        Surface(
+            shape = CircleShape,
+            color = containerColor,
+            contentColor = contentColor,
+            modifier = modifier,
+        ) {
+            Column(
+                // XR-changed: Original NavigationRail uses fillMaxHeight() and windowInsets,
+                // which do not produce the desired result in XR.
+                Modifier.widthIn(min = XrNavigationRailTokens.ContainerWidth)
+                    .padding(vertical = XrNavigationRailTokens.VerticalPadding)
+                    .selectableGroup(),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(XrNavigationRailTokens.VerticalPadding),
+                content = content,
+            )
+        }
+    }
+    // Header goes inside a separate Orbiter without an outline shape, as this is generally
+    // a FAB.
+    if (header != null) {
+        Orbiter(
+            position = OrbiterEdge.Start,
+            alignment = Alignment.Top,
+            offset = XrNavigationRailTokens.OrbiterEdgeOffset,
+        ) {
+            Column(
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(XrNavigationRailTokens.VerticalPadding),
+                content = header,
+            )
+        }
+    }
+}
+
+private object XrNavigationRailTokens {
+    /** The [EdgeOffset] for NavigationRail Orbiters in Full Space Mode (FSM). */
+    val OrbiterEdgeOffset
+        @Composable get() = EdgeOffset.inner(24.dp)
+
+    /**
+     * Vertical padding between the contents of the [NavigationRail] and its top/bottom, and
+     * internally between items.
+     *
+     * XR-changed value to match desired UX.
+     */
+    val VerticalPadding: Dp = 20.dp
+
+    val ContainerWidth = 96.0.dp
+}
+
+/** [NavigationRailComponentOverride] that uses the XR-specific [NavigationRail]. */
+@ExperimentalMaterial3XrApi
+@OptIn(ExperimentalMaterial3Api::class)
+internal object XrNavigationRailComponentOverride : NavigationRailComponentOverride {
+    @Composable
+    override fun NavigationRailComponentOverrideContext.NavigationRail() {
+        NavigationRail(
+            modifier = modifier,
+            containerColor = containerColor,
+            contentColor = contentColor,
+            header = header,
+            content = content,
+        )
+    }
+}
diff --git a/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/androidx-xr-compose-material3-material3-documentation.md b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/androidx-xr-compose-material3-material3-documentation.md
new file mode 100644
index 0000000..5d053b2
--- /dev/null
+++ b/xr/compose/material3/material3/src/main/java/androidx/xr/compose/material3/androidx-xr-compose-material3-material3-documentation.md
@@ -0,0 +1,7 @@
+# Module root
+
+androidx.xr.compose.material3 material3
+
+# Package androidx.xr.compose.material3
+
+Compose Material3 XR
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/runtime/runtime-openxr/api/current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/current.txt
copy to xr/runtime/runtime-openxr/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/runtime/runtime-openxr/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/runtime/runtime-openxr/api/res-current.txt
diff --git a/xr/runtime/runtime-openxr/api/restricted_current.txt b/xr/runtime/runtime-openxr/api/restricted_current.txt
new file mode 100644
index 0000000..69ed52e
--- /dev/null
+++ b/xr/runtime/runtime-openxr/api/restricted_current.txt
@@ -0,0 +1,109 @@
+// Signature format: 4.0
+package androidx.xr.runtime.openxr {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class AnchorState {
+    ctor public AnchorState();
+    ctor public AnchorState(optional androidx.xr.runtime.internal.TrackingState trackingState, optional androidx.xr.runtime.math.Pose? pose);
+    method public androidx.xr.runtime.internal.TrackingState component1();
+    method public androidx.xr.runtime.math.Pose? component2();
+    method public androidx.xr.runtime.openxr.AnchorState copy(androidx.xr.runtime.internal.TrackingState trackingState, androidx.xr.runtime.math.Pose? pose);
+    method public androidx.xr.runtime.math.Pose? getPose();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    property public final androidx.xr.runtime.math.Pose? pose;
+    property public final androidx.xr.runtime.internal.TrackingState trackingState;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ExportableAnchor extends androidx.xr.runtime.internal.Anchor {
+    method public android.os.IBinder getAnchorToken();
+    method public long getNativePointer();
+    property public abstract android.os.IBinder anchorToken;
+    property public abstract long nativePointer;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class HitData {
+    ctor public HitData(androidx.xr.runtime.math.Pose pose, long id);
+    method public androidx.xr.runtime.math.Pose component1();
+    method public long component2();
+    method public androidx.xr.runtime.openxr.HitData copy(androidx.xr.runtime.math.Pose pose, long id);
+    method public long getId();
+    method public androidx.xr.runtime.math.Pose getPose();
+    property public final long id;
+    property public final androidx.xr.runtime.math.Pose pose;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrAnchor implements androidx.xr.runtime.openxr.ExportableAnchor {
+    method public void detach();
+    method public android.os.IBinder getAnchorToken();
+    method public long getNativePointer();
+    method public androidx.xr.runtime.internal.Anchor.PersistenceState getPersistenceState();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public java.util.UUID? getUuid();
+    method public void persist();
+    method public void update(long xrTime);
+    property public android.os.IBinder anchorToken;
+    property public long nativePointer;
+    property public androidx.xr.runtime.internal.Anchor.PersistenceState persistenceState;
+    property public androidx.xr.runtime.math.Pose pose;
+    property public androidx.xr.runtime.internal.TrackingState trackingState;
+    property public java.util.UUID? uuid;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrManager implements androidx.xr.runtime.internal.LifecycleManager {
+    method public void configure();
+    method public void create();
+    method public void pause();
+    method public void resume();
+    method public void stop();
+    method public suspend Object? update(kotlin.coroutines.Continuation<? super kotlin.time.ComparableTimeMark>);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrPerceptionManager implements androidx.xr.runtime.internal.PerceptionManager {
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public java.util.List<java.util.UUID> getPersistedAnchorUuids();
+    method public java.util.Collection<androidx.xr.runtime.internal.Trackable> getTrackables();
+    method public java.util.List<androidx.xr.runtime.internal.HitResult> hitTest(androidx.xr.runtime.math.Ray ray);
+    method public androidx.xr.runtime.internal.Anchor loadAnchor(java.util.UUID uuid);
+    method public androidx.xr.runtime.internal.Anchor loadAnchorFromNativePointer(long nativePointer);
+    method public void unpersistAnchor(java.util.UUID uuid);
+    method public void update(long xrTime);
+    property public java.util.Collection<androidx.xr.runtime.internal.Trackable> trackables;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrPlane implements androidx.xr.runtime.internal.Plane {
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.runtime.math.Pose getCenterPose();
+    method public androidx.xr.runtime.math.Vector2 getExtents();
+    method public androidx.xr.runtime.internal.Plane.Label getLabel();
+    method public androidx.xr.runtime.internal.Plane? getSubsumedBy();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public androidx.xr.runtime.internal.Plane.Type getType();
+    method public java.util.List<androidx.xr.runtime.math.Vector2> getVertices();
+    method public void update(long xrTime);
+    property public androidx.xr.runtime.math.Pose centerPose;
+    property public androidx.xr.runtime.math.Vector2 extents;
+    property public androidx.xr.runtime.internal.Plane.Label label;
+    property public androidx.xr.runtime.internal.Plane? subsumedBy;
+    property public androidx.xr.runtime.internal.TrackingState trackingState;
+    property public androidx.xr.runtime.internal.Plane.Type type;
+    property public java.util.List<androidx.xr.runtime.math.Vector2> vertices;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrRuntime implements androidx.xr.runtime.internal.Runtime {
+    method public androidx.xr.runtime.openxr.OpenXrManager getLifecycleManager();
+    method public androidx.xr.runtime.openxr.OpenXrPerceptionManager getPerceptionManager();
+    property public androidx.xr.runtime.openxr.OpenXrManager lifecycleManager;
+    property public androidx.xr.runtime.openxr.OpenXrPerceptionManager perceptionManager;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class OpenXrRuntimeFactory implements androidx.xr.runtime.internal.RuntimeFactory {
+    ctor public OpenXrRuntimeFactory();
+    method public androidx.xr.runtime.internal.Runtime createRuntime(android.app.Activity activity);
+    field public static final androidx.xr.runtime.openxr.OpenXrRuntimeFactory.Companion Companion;
+  }
+
+  public static final class OpenXrRuntimeFactory.Companion {
+  }
+
+}
+
diff --git a/xr/runtime/runtime-openxr/build.gradle b/xr/runtime/runtime-openxr/build.gradle
new file mode 100644
index 0000000..5991b63
--- /dev/null
+++ b/xr/runtime/runtime-openxr/build.gradle
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.AndroidXConfig
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
+    api(project(":xr:runtime:runtime"))
+
+    implementation("androidx.annotation:annotation:1.8.1")
+
+    androidTestImplementation(libs.kotlinCoroutinesTest)
+    androidTestImplementation(libs.junit)
+    androidTestImplementation(libs.testExtJunit)
+    androidTestImplementation(libs.testRunner)
+    androidTestImplementation(libs.truth)
+    androidTestImplementation("androidx.appcompat:appcompat:1.2.0")
+    androidTestImplementation(project(":kruth:kruth"))
+}
+
+android {
+    namespace = "androidx.xr.runtime.openxr"
+    sourceSets.main {
+        resources.srcDirs += "src/main/resources"
+        jniLibs.srcDirs += new File(AndroidXConfig.getPrebuiltsRoot(project), "androidx/xr/android")
+    }
+    defaultConfig {
+        consumerProguardFiles 'proguard-rules.pro'
+    }
+    sourceSets.androidTest {
+        jniLibs.srcDirs += new File(AndroidXConfig.getPrebuiltsRoot(project), "androidx/xr/androidTest")
+    }
+}
+
+androidx {
+    name = "XR OpenXR Runtime"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "An implementation of the XR runtime using OpenXR."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/runtime/runtime-openxr/proguard-rules.pro b/xr/runtime/runtime-openxr/proguard-rules.pro
new file mode 100644
index 0000000..7c4c9f0
--- /dev/null
+++ b/xr/runtime/runtime-openxr/proguard-rules.pro
@@ -0,0 +1,5 @@
+# Prevent the OpenXR classes from being obfuscated as they are created from native code.
+-keep class androidx.xr.runtime.openxr.** { *; }
+-keep class androidx.xr.runtime.openxr.**$* { *; }
+-keep class * extends androidx.xr.runtime.openxr.** { *; }
+-keep class * extends androidx.xr.runtime.openxr.**$* { *; }
diff --git a/xr/runtime/runtime-openxr/src/androidTest/AndroidManifest.xml b/xr/runtime/runtime-openxr/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..5f3ccf2
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<!--
+  Copyright 2024 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application>
+        <activity
+            android:name="android.app.Activity"
+            android:theme="@style/Theme.AppCompat"
+            android:exported="true"/>
+    </application>
+</manifest>
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/AnchorStateTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/AnchorStateTest.kt
new file mode 100644
index 0000000..341824cd
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/AnchorStateTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.xr.runtime.internal.TrackingState
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class AnchorStateTest {
+
+    @Test
+    fun constructor_noArguments_returnsZeroVectorAndIdentityQuaternion() {
+        val pose = AnchorState().pose!!
+
+        assertThat(pose.translation.x).isEqualTo(0)
+        assertThat(pose.translation.y).isEqualTo(0)
+        assertThat(pose.translation.z).isEqualTo(0)
+        assertThat(pose.rotation.x).isEqualTo(0)
+        assertThat(pose.rotation.y).isEqualTo(0)
+        assertThat(pose.rotation.z).isEqualTo(0)
+        assertThat(pose.rotation.w).isEqualTo(1)
+    }
+
+    @Test
+    fun constructor_noArguments_returnsPausedTrackingState() {
+        val underTest = AnchorState()
+
+        assertThat(underTest.trackingState).isEqualTo(TrackingState.Paused)
+    }
+
+    @Test
+    fun constructor_TrackingAndNullPose_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> {
+            AnchorState(trackingState = TrackingState.Tracking, pose = null)
+        }
+    }
+
+    @Test
+    fun constructor_PausedAndNullPose_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> {
+            AnchorState(trackingState = TrackingState.Paused, pose = null)
+        }
+    }
+
+    @Test
+    fun fromOpenXrLocationFlags_IncorrectBitPositions_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> {
+            TrackingState.fromOpenXrLocationFlags(0x0000FFFF)
+        }
+    }
+
+    @Test
+    fun fromOpenXrLocationFlags_OnlyValidBitsFlipped_returnsPausedTrackingState() {
+        val trackingState = TrackingState.fromOpenXrLocationFlags(0x00000003) // 0b...0011
+
+        assertThat(trackingState).isEqualTo(TrackingState.Paused)
+    }
+
+    @Test
+    fun fromOpenXrLocationFlags_ValidAndTrackingBitsFlipped_returnsTrackingTrackingState() {
+        val trackingState = TrackingState.fromOpenXrLocationFlags(0x0000000F) // 0b...1111
+
+        assertThat(trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun fromOpenXrLocationFlags_OnlyTrackingBitsFlipped_returnsStoppedTrackingState() {
+        val trackingState = TrackingState.fromOpenXrLocationFlags(0x0000000C) // 0b...1100
+
+        assertThat(trackingState).isEqualTo(TrackingState.Stopped)
+    }
+
+    @Test
+    fun fromOpenXrLocationFlags_OneTrackingAndValidBitFlipped_returnsStoppedTrackingState() {
+        val trackingState = TrackingState.fromOpenXrLocationFlags(0x0000000A) // 0b...1010
+
+        assertThat(trackingState).isEqualTo(TrackingState.Stopped)
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrAnchorTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrAnchorTest.kt
new file mode 100644
index 0000000..9ff67e5
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrAnchorTest.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrAnchorTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    lateinit private var openXrManager: OpenXrManager
+    lateinit private var xrResources: XrResources
+    lateinit private var underTest: OpenXrAnchor
+
+    @Before
+    fun setUp() {
+        xrResources = XrResources()
+        underTest = OpenXrAnchor(nativePointer = 1, xrResources = xrResources)
+        xrResources.addUpdatable(underTest as Updatable)
+    }
+
+    @Test
+    fun update_updatesPose() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.pose == Pose())
+
+        underTest.update(xrTime)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.pose)
+            .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+    }
+
+    @Test
+    fun update_updatesTrackingState() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.trackingState == TrackingState.Paused)
+
+        underTest.update(xrTime)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from the tracking state corresponding to `kLocationFlags` defined in
+        // //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun persist_updatesUuidAndPersistenceState() = initOpenXrManagerAndRunTest {
+        check(underTest.persistenceState == Anchor.PersistenceState.NotPersisted)
+        check(underTest.uuid == null)
+
+        underTest.persist()
+
+        assertThat(underTest.persistenceState).isEqualTo(Anchor.PersistenceState.Pending)
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kUuid` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.uuid)
+            .isEqualTo(UUID.fromString("01020304-0506-0708-090a-0b0c0d0e0f10"))
+    }
+
+    @Test
+    fun persist_calledTwice_doesNotChangeUuidAndState() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        underTest.persist()
+        underTest.update(xrTime)
+        check(underTest.uuid != null)
+        check(underTest.persistenceState == Anchor.PersistenceState.Persisted)
+
+        underTest.persist()
+
+        assertThat(underTest.persistenceState).isEqualTo(Anchor.PersistenceState.Persisted)
+    }
+
+    @Test
+    fun update_updatesPersistenceState() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        underTest.persist()
+        check(underTest.persistenceState == Anchor.PersistenceState.Pending)
+
+        underTest.update(xrTime)
+
+        assertThat(underTest.persistenceState).isEqualTo(Anchor.PersistenceState.Persisted)
+    }
+
+    @Test
+    fun detach_removesAnchorFromXrResources() = initOpenXrManagerAndRunTest {
+        check(xrResources.updatables.contains(underTest))
+
+        underTest.detach()
+
+        assertThat(xrResources.updatables).doesNotContain(underTest)
+    }
+
+    @Test
+    fun fromOpenXrPersistenceState_returnsCorrectPersistenceStateValues() {
+        // XR_ANCHOR_PERSIST_STATE_PERSIST_NOT_REQUESTED_ANDROID
+        assertThat(Anchor.PersistenceState.fromOpenXrPersistenceState(0))
+            .isEqualTo(Anchor.PersistenceState.NotPersisted)
+        // XR_ANCHOR_PERSIST_STATE_PERSIST_PENDING_ANDROID
+        assertThat(Anchor.PersistenceState.fromOpenXrPersistenceState(1))
+            .isEqualTo(Anchor.PersistenceState.Pending)
+        // XR_ANCHOR_PERSIST_STATE_PERSISTED_ANDROID
+        assertThat(Anchor.PersistenceState.fromOpenXrPersistenceState(2))
+            .isEqualTo(Anchor.PersistenceState.Persisted)
+    }
+
+    private fun initOpenXrManagerAndRunTest(testBody: () -> Unit) {
+        activityRule.scenario.onActivity {
+            val timeSource = OpenXrTimeSource()
+            val perceptionManager = OpenXrPerceptionManager(timeSource)
+            openXrManager = OpenXrManager(it, perceptionManager, timeSource)
+            openXrManager.create()
+            openXrManager.resume()
+
+            testBody()
+
+            // Pause and stop the OpenXR manager here in lieu of an @After method to ensure that the
+            // calls to the OpenXR manager are coming from the same thread.
+            openXrManager.pause()
+            openXrManager.stop()
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrManagerTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrManagerTest.kt
new file mode 100644
index 0000000..d1a36c5
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrManagerTest.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrManagerTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    private lateinit var underTest: OpenXrManager
+    private lateinit var perceptionManager: OpenXrPerceptionManager
+    private lateinit var timeSource: OpenXrTimeSource
+
+    @Before
+    fun setUp() {
+        timeSource = OpenXrTimeSource()
+        perceptionManager = OpenXrPerceptionManager(timeSource)
+    }
+
+    @Test
+    fun create_initializesNativeOpenXrManager() = initOpenXrManagerAndRunTest {
+        check(underTest.nativePointer == 0L)
+
+        underTest.create()
+
+        assertThat(underTest.nativePointer).isGreaterThan(0)
+    }
+
+    @Test
+    fun create_afterStop_initializesNativeOpenXrManager() = initOpenXrManagerAndRunTest {
+        underTest.create()
+        underTest.stop()
+        check(underTest.nativePointer == 0L)
+
+        underTest.create()
+
+        assertThat(underTest.nativePointer).isGreaterThan(0L)
+    }
+
+    // TODO: b/344962771 - Add a more meaningful test once we can use the update() method.
+    @Test
+    fun resume_doesNotThrowIllegalStateException() = initOpenXrManagerAndRunTest {
+        underTest.create()
+
+        underTest.resume()
+    }
+
+    @Test
+    fun resume_afterStopAndCreate_doesNotThrowIllegalStateException() =
+        initOpenXrManagerAndRunTest {
+            underTest.create()
+            underTest.stop()
+            check(underTest.nativePointer == 0L)
+            underTest.create()
+
+            underTest.resume()
+        }
+
+    @Test
+    fun update_updatesPerceptionManager() = initOpenXrManagerAndRunTest {
+        runTest {
+            underTest.create()
+            underTest.resume()
+            check(perceptionManager.trackables.isEmpty())
+
+            underTest.update()
+
+            assertThat(perceptionManager.trackables).isNotEmpty()
+        }
+    }
+
+    @Test
+    // TODO - b/346615429: Control the values returned by the OpenXR stub instead of relying on the
+    // stub's current implementation.
+    fun update_returnsTimeMarkFromTimeSource() = initOpenXrManagerAndRunTest {
+        runTest {
+            underTest.create()
+            underTest.resume()
+
+            // The OpenXR stub returns a different value for each call to [OpenXrTimeSource::read]
+            // in
+            // increments of 1000ns when `xrConvertTimespecTimeToTimeKHR` is executed. The first
+            // call
+            // returns 1000ns and is the value associated with [timeMark]. The second call returns
+            // 2000ns
+            // and is the value associated with [AbstractLongTimeSource::zero], which is calculated
+            // automatically with the first call to [OpenXrTimeSource::markNow].
+            // Note that this is just an idiosyncrasy of the test stub and not how OpenXR works in
+            // practice,
+            // where the second call would return an almost identical value to the first call's
+            // value.
+            val timeMark = underTest.update()
+
+            // The third call happens with the call to [elapsedNow] and returns 3000ns. Thus, the
+            // elapsed
+            // time is 3000ns (i.e. "now") -  1000ns (i.e. "the start time") = 2000ns.
+            assertThat(timeMark.elapsedNow().inWholeNanoseconds).isEqualTo(2000L)
+        }
+    }
+
+    // TODO: b/344962771 - Add a more meaningful test once we can use the update() method.
+    @Test
+    fun pause_doesNotThrowIllegalStateException() = initOpenXrManagerAndRunTest {
+        underTest.create()
+        underTest.resume()
+
+        underTest.pause()
+    }
+
+    @Test
+    fun pause_afterStop_throwsIllegalStateException() = initOpenXrManagerAndRunTest {
+        underTest.create()
+        underTest.stop()
+
+        assertThrows(IllegalStateException::class.java) { underTest.pause() }
+    }
+
+    @Test
+    fun stop_destroysNativeOpenXrManager() = initOpenXrManagerAndRunTest {
+        underTest.create()
+        check(underTest.nativePointer != 0L)
+
+        underTest.stop()
+
+        assertThat(underTest.nativePointer).isEqualTo(0L)
+    }
+
+    private fun initOpenXrManagerAndRunTest(testBody: () -> Unit) {
+        activityRule.scenario.onActivity {
+            underTest = OpenXrManager(it, perceptionManager, timeSource)
+
+            testBody()
+
+            // Stop the OpenXR manager here in lieu of an @After method to ensure that the
+            // calls to the OpenXR manager are coming from the same thread.
+            underTest.stop()
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManagerTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManagerTest.kt
new file mode 100644
index 0000000..55f35b7
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManagerTest.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Ray
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrPerceptionManagerTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+
+        const val XR_TIME = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    lateinit var openXrManager: OpenXrManager
+    lateinit var underTest: OpenXrPerceptionManager
+
+    @Before
+    fun setUp() {
+        underTest = OpenXrPerceptionManager(OpenXrTimeSource())
+    }
+
+    @After
+    fun tearDown() {
+        underTest.clear()
+    }
+
+    @Test
+    fun createAnchor_returnsAnchorWithTheGivenPose() = initOpenXrManagerAndRunTest {
+        underTest.update(XR_TIME)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        val pose = Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f))
+        val anchor = underTest.createAnchor(pose)
+
+        assertThat(anchor.pose).isEqualTo(pose)
+    }
+
+    @Test
+    fun detachAnchor_removesAnchorWhenItDetaches() = initOpenXrManagerAndRunTest {
+        underTest.update(XR_TIME)
+
+        val anchor = underTest.createAnchor(Pose())
+        check(underTest.xrResources.updatables.contains(anchor as Updatable))
+
+        anchor.detach()
+
+        assertThat(underTest.xrResources.updatables).doesNotContain(anchor as Updatable)
+    }
+
+    @Test
+    fun update_updatesTrackables() = initOpenXrManagerAndRunTest {
+        // TODO: b/345314278 -- Add more meaningful tests once trackables are implemented properly
+        // and a
+        // fake perception library can be used mock trackables.
+        underTest.update(XR_TIME)
+
+        assertThat(underTest.trackables).hasSize(1)
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat((underTest.trackables.first() as OpenXrPlane).centerPose)
+            .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+    }
+
+    @Test
+    fun hitTest_returnsHitResults() = initOpenXrManagerAndRunTest {
+        underTest.update(XR_TIME)
+        check(underTest.trackables.isNotEmpty())
+        val trackable = underTest.trackables.first() as OpenXrPlane
+
+        // TODO: b/345314278 -- Add more meaningful tests once trackables are implemented properly
+        // and a
+        // fake perception library can be used to mock trackables.
+        val hitResults = underTest.hitTest(Ray(Vector3(4f, 3f, 2f), Vector3(2f, 1f, 0f)))
+
+        assertThat(hitResults).hasSize(1)
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(hitResults.first().hitPose)
+            .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+        assertThat(hitResults.first().trackable).isEqualTo(trackable)
+        assertThat(hitResults.first().distance).isEqualTo(5f) // sqrt((4-0)^2 + (3-0)^2 + (2-2)^2)
+    }
+
+    @Test
+    fun getPersistedAnchorUuids_returnsStubUuid() = initOpenXrManagerAndRunTest {
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kUuid` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.getPersistedAnchorUuids())
+            .containsExactly(UUID.fromString("01020304-0506-0708-090a-0b0c0d0e0f10"))
+    }
+
+    @Test
+    fun loadAnchor_returnsAnchorWithGivenUuidAndPose() = initOpenXrManagerAndRunTest {
+        // The stub doesn't care about the UUID, so we can use any UUID.
+        val uuid = UUID.randomUUID()
+        val anchor = underTest.loadAnchor(uuid)
+
+        assertThat(anchor.uuid).isEqualTo(uuid)
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(anchor.pose)
+            .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+    }
+
+    @Test
+    fun loadAnchorFromNativePointer_returnsAnchorWithGivenNativePointer() =
+        initOpenXrManagerAndRunTest {
+            val anchor = underTest.loadAnchorFromNativePointer(123L) as OpenXrAnchor
+            assertThat(anchor.nativePointer).isEqualTo(123L)
+
+            // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time
+            // being they
+            // come from `kPose` defined in
+            // //third_party/arcore/androidx/native/openxr/openxr_stub.cc
+            assertThat(anchor.pose)
+                .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+        }
+
+    @Test
+    fun unpersistAnchor_doesNotThrowIllegalStateException() = initOpenXrManagerAndRunTest {
+        underTest.unpersistAnchor(UUID.randomUUID())
+    }
+
+    @Test
+    fun clear_clearXrResources() = initOpenXrManagerAndRunTest {
+        underTest.update(XR_TIME)
+        underTest.createAnchor(Pose())
+        check(underTest.trackables.isNotEmpty())
+        check(underTest.xrResources.trackablesMap.isNotEmpty())
+        check(underTest.xrResources.updatables.isNotEmpty())
+
+        underTest.clear()
+
+        assertThat(underTest.trackables).isEmpty()
+        assertThat(underTest.xrResources.trackablesMap).isEmpty()
+        assertThat(underTest.xrResources.updatables).isEmpty()
+    }
+
+    private fun initOpenXrManagerAndRunTest(testBody: () -> Unit) {
+        activityRule.scenario.onActivity {
+            val timeSource = OpenXrTimeSource()
+            openXrManager = OpenXrManager(it, underTest, timeSource)
+            openXrManager.create()
+            openXrManager.resume()
+
+            testBody()
+
+            // Pause and stop the OpenXR manager here in lieu of an @After method to ensure that the
+            // calls to the OpenXR manager are coming from the same thread.
+            openXrManager.pause()
+            openXrManager.stop()
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPlaneTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPlaneTest.kt
new file mode 100644
index 0000000..6eaf785
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrPlaneTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.xr.runtime.internal.Plane
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector2
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrPlaneTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    private val planeId = 1L
+
+    lateinit private var openXrManager: OpenXrManager
+    lateinit private var xrResources: XrResources
+    lateinit private var underTest: OpenXrPlane
+
+    @Before
+    fun setUp() {
+        xrResources = XrResources()
+        underTest =
+            OpenXrPlane(planeId, Plane.Type.HorizontalUpwardFacing, OpenXrTimeSource(), xrResources)
+        xrResources.addTrackable(planeId, underTest)
+        xrResources.addUpdatable(underTest as Updatable)
+    }
+
+    @After
+    fun tearDown() {
+        xrResources.clear()
+    }
+
+    @Test
+    fun createAnchor_addsAnchor() = initOpenXrManagerAndRunTest {
+        check(xrResources.updatables.size == 1)
+        check(xrResources.updatables.contains(underTest))
+
+        val anchor = underTest.createAnchor(Pose())
+
+        assertThat(xrResources.updatables).containsExactly(underTest, anchor as Updatable)
+    }
+
+    @Test
+    fun detachAnchor_removesAnchorWhenItDetaches() = initOpenXrManagerAndRunTest {
+        val anchor = underTest.createAnchor(Pose())
+        check(xrResources.updatables.size == 2)
+        check(xrResources.updatables.contains(underTest))
+        check(xrResources.updatables.contains(anchor as Updatable))
+
+        anchor.detach()
+
+        assertThat(xrResources.updatables).containsExactly(underTest)
+    }
+
+    @Test
+    fun update_updatesTrackingState() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.trackingState.equals(TrackingState.Paused))
+
+        underTest.update(xrTime)
+
+        assertThat(underTest.trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun update_updatesCenterPose() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.centerPose == Pose())
+
+        underTest.update(xrTime)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.centerPose)
+            .isEqualTo(Pose(Vector3(0f, 0f, 2.0f), Quaternion(0f, 1.0f, 0f, 1.0f)))
+    }
+
+    @Test
+    fun update_updatesExtents() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.centerPose == Pose())
+
+        underTest.update(xrTime)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kPose` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.extents).isEqualTo(Vector2(1.0f, 2.0f))
+    }
+
+    @Test
+    fun update_updatesVertices() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.vertices.isEmpty())
+
+        underTest.update(xrTime)
+
+        // TODO - b/346615429: Define values here using the stub's Kotlin API. For the time being
+        // they
+        // come from `kVertices` defined in //third_party/jetpack_xr_natives/openxr/openxr_stub.cc
+        assertThat(underTest.vertices.size).isEqualTo(4)
+        assertThat(underTest.vertices[0]).isEqualTo(Vector2(2.0f, 0.0f))
+        assertThat(underTest.vertices[1]).isEqualTo(Vector2(2.0f, 2.0f))
+        assertThat(underTest.vertices[2]).isEqualTo(Vector2(0.0f, 0.0f))
+        assertThat(underTest.vertices[3]).isEqualTo(Vector2(0.0f, 2.0f))
+    }
+
+    @Test
+    fun update_updatesSubsumedBy() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        val planeSubsumedId = 67890L
+        val planeSubsumed: OpenXrPlane =
+            OpenXrPlane(
+                planeSubsumedId,
+                Plane.Type.HorizontalUpwardFacing,
+                OpenXrTimeSource(),
+                xrResources,
+            )
+        xrResources.addTrackable(planeSubsumedId, planeSubsumed)
+        xrResources.addUpdatable(planeSubsumed as Updatable)
+        check(planeSubsumed.subsumedBy == null)
+        check(xrResources.trackablesMap.containsKey(planeId))
+
+        planeSubsumed.update(xrTime)
+
+        assertThat(planeSubsumed.subsumedBy).isEqualTo(underTest)
+    }
+
+    @Test
+    fun update_noSubsumedByPlanes_setsSubsumedByToNull() = initOpenXrManagerAndRunTest {
+        val xrTime = 50L * 1_000_000 // 50 milliseconds in nanoseconds.
+        check(underTest.subsumedBy == null)
+
+        underTest.update(xrTime)
+
+        assertThat(underTest.subsumedBy).isNull()
+    }
+
+    @Test
+    fun fromOpenXrType_withValidValue_convertsType() {
+        val planeType: Plane.Type = Plane.Type.fromOpenXrType(0)
+
+        assertThat(planeType).isEqualTo(Plane.Type.HorizontalDownwardFacing)
+    }
+
+    @Test
+    fun fromOpenXrType_withInvalidValue_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> { Plane.Type.fromOpenXrType(3) }
+    }
+
+    @Test
+    fun fromOpenXrLabel_withValidValue_convertsLabel() {
+        val planeLabel: Plane.Label = Plane.Label.fromOpenXrLabel(0)
+
+        assertThat(planeLabel).isEqualTo(Plane.Label.Unknown)
+    }
+
+    @Test
+    fun fromOpenXrLabel_withInvalidValue_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> { Plane.Label.fromOpenXrLabel(5) }
+    }
+
+    private fun initOpenXrManagerAndRunTest(testBody: () -> Unit) {
+        activityRule.scenario.onActivity {
+            val timeSource = OpenXrTimeSource()
+            val perceptionManager = OpenXrPerceptionManager(timeSource)
+            openXrManager = OpenXrManager(it, perceptionManager, timeSource)
+            openXrManager.create()
+            openXrManager.resume()
+
+            testBody()
+
+            // Pause and stop the OpenXR manager here in lieu of an @After method to ensure that the
+            // calls to the OpenXR manager are coming from the same thread.
+            openXrManager.pause()
+            openXrManager.stop()
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactoryTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactoryTest.kt
new file mode 100644
index 0000000..a52a506
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactoryTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.xr.runtime.internal.RuntimeFactory
+import com.google.common.truth.Truth.assertThat
+import java.util.ServiceLoader
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrRuntimeFactoryTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    @Test
+    fun class_isDiscoverableViaServiceLoader() {
+        assertThat(ServiceLoader.load(RuntimeFactory::class.java).iterator().next())
+            .isInstanceOf(OpenXrRuntimeFactory::class.java)
+    }
+
+    @Test
+    fun createRuntime_createsOpenXrRuntime() {
+        activityRule.scenario.onActivity {
+            val underTest = OpenXrRuntimeFactory()
+
+            assertThat(underTest.createRuntime(it)).isInstanceOf(OpenXrRuntime::class.java)
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSourceTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSourceTest.kt
new file mode 100644
index 0000000..b69c475
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSourceTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO - b/382119583: Remove the @SdkSuppress annotation once "androidx.xr.runtime.openxr.test"
+// supports a
+// lower SDK version.
+@SdkSuppress(minSdkVersion = 29)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class OpenXrTimeSourceTest {
+
+    companion object {
+        init {
+            System.loadLibrary("androidx.xr.runtime.openxr.test")
+        }
+    }
+
+    @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
+
+    private lateinit var underTest: OpenXrTimeSource
+    private lateinit var openXrManager: OpenXrManager
+
+    @Before
+    fun setUp() {
+        underTest = OpenXrTimeSource()
+    }
+
+    @Test
+    // TODO - b/346615429: Control the values returned by the OpenXR stub instead of relying on the
+    // stub's current implementation.
+    fun read_usesTheOpenXrClock() = initOpenXrManagerAndRunTest {
+        // The OpenXR stub returns a different value for each call to [OpenXrTimeSource::read] in
+        // increments of 1000ns when `xrConvertTimespecTimeToTimeKHR` is executed. The first call
+        // returns 1000ns and is the value associated with [timeMark]. The second call returns
+        // 2000ns
+        // and is the value associated with [AbstractLongTimeSource::zero], which is calculated
+        // automatically with the first call to [OpenXrTimeSource::markNow].
+        // Note that this is just an idiosyncrasy of the test stub and not how OpenXR works in
+        // practice,
+        // where the second call would return an almost identical value to the first call's value.
+        val timeMark = underTest.markNow()
+
+        // The third call happens with the call to [elapsedNow] and returns 3000ns. Thus, the
+        // elapsed
+        // time is 3000ns (i.e. "now") -  1000ns (i.e. "the start time") = 2000ns.
+        assertThat(timeMark.elapsedNow().inWholeNanoseconds).isEqualTo(2000L)
+    }
+
+    @Test
+    // TODO - b/346615429: Control the values returned by the OpenXR stub instead of relying on the
+    // stub's current implementation.
+    fun getXrTime_returnsTheOpenXrTime() = initOpenXrManagerAndRunTest {
+        // The OpenXR stub returns a different value for each call to [OpenXrTimeSource::read] in
+        // increments of 1000ns when `xrConvertTimespecTimeToTimeKHR` is executed. The first call
+        // returns 1000ns and is the value associated with [firstTimeMark]. The second call returns
+        // 2000ns and is the value associated with [AbstractLongTimeSource::zero], which is
+        // calculated
+        // automatically with the first call to [OpenXrTimeSource::markNow].
+        // Note that this is just an idiosyncrasy of the test stub and not how OpenXR works in
+        // practice,
+        // where the second call would return an almost identical value to the first call's value.
+        val firstTimeMark = underTest.getXrTime(underTest.markNow())
+        // The third call returns 3000ns and is the value associated with [secondTimeMark].
+        val secondTimeMark = underTest.getXrTime(underTest.markNow())
+        // The fourth call returns 4000ns and is the value associated with [thirdTimeMark].
+        val thirdTimeMark = underTest.getXrTime(underTest.markNow())
+
+        assertThat(secondTimeMark).isEqualTo(firstTimeMark + 2000L)
+        assertThat(thirdTimeMark).isEqualTo(firstTimeMark + 3000L)
+    }
+
+    private fun initOpenXrManagerAndRunTest(testBody: () -> Unit) {
+        activityRule.scenario.onActivity {
+            openXrManager = OpenXrManager(it, OpenXrPerceptionManager(underTest), underTest)
+            openXrManager.create()
+            openXrManager.resume()
+
+            testBody()
+
+            // Pause and stop the OpenXR manager here in lieu of an @After method to ensure that the
+            // calls to the OpenXR manager are coming from the same thread.
+            openXrManager.pause()
+            openXrManager.stop()
+        }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/PlaneStateTest.kt b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/PlaneStateTest.kt
new file mode 100644
index 0000000..f4a4686
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/androidTest/kotlin/androidx/xr/runtime/openxr/PlaneStateTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.xr.runtime.internal.TrackingState
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PlaneStateTest {
+
+    @Test
+    fun constructor_noArguments_returnsZeroVectorAndIdentityQuaternion() {
+        val underTest = PlaneState()
+
+        assertThat(underTest.centerPose.translation.x).isEqualTo(0)
+        assertThat(underTest.centerPose.translation.y).isEqualTo(0)
+        assertThat(underTest.centerPose.translation.z).isEqualTo(0)
+        assertThat(underTest.centerPose.rotation.x).isEqualTo(0)
+        assertThat(underTest.centerPose.rotation.y).isEqualTo(0)
+        assertThat(underTest.centerPose.rotation.z).isEqualTo(0)
+        assertThat(underTest.centerPose.rotation.w).isEqualTo(1)
+        assertThat(underTest.extents.x).isEqualTo(0)
+        assertThat(underTest.extents.y).isEqualTo(0)
+        assertThat(underTest.vertices.size).isEqualTo(0)
+    }
+
+    @Test
+    fun fromOpenXrTrackingState_withValidValue_convertsTrackingState() {
+        val trackingState: TrackingState = TrackingState.fromOpenXrTrackingState(0)
+
+        assertThat(trackingState).isEqualTo(TrackingState.Paused)
+    }
+
+    @Test
+    fun fromOpenXrTrackingState_withInvalidValue_throwsIllegalArgumentException() {
+        assertFailsWith<IllegalArgumentException> { TrackingState.fromOpenXrTrackingState(3) }
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/AnchorState.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/AnchorState.kt
new file mode 100644
index 0000000..1295d25
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/AnchorState.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Represents the current state of an [Anchor] instance's mutable fields.
+ *
+ * @property trackingState the [TrackingState] value describing if the anchor is being updated.
+ * @property pose the pose of the center of the detected anchor. Can be null iff the tracking state
+ *   is [TrackingState.Stopped].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class AnchorState(
+    val trackingState: TrackingState = TrackingState.Paused,
+    val pose: Pose? = Pose(),
+) {
+    init {
+        require(pose != null || trackingState == TrackingState.Stopped) {
+            "Pose cannot be null if tracking state is not STOPPED."
+        }
+    }
+}
+
+/**
+ * Create a [TrackingState] inferred from the [XrSpaceLocationFlags] returned with the anchor
+ * location data. The following rules are used to determine the [TrackingState]:
+ * * If both valid and tracking bits are flipped, return [TrackingState.Tracking]
+ * * If both valid bits are flipped, but not both tracking bits, return [TrackingState.Paused]
+ * * Any other combination of flipped bits (i.e. both valid bits are not flipped), return
+ *   [TrackingState.Stopped]
+ */
+internal fun TrackingState.Companion.fromOpenXrLocationFlags(flags: Int): TrackingState {
+    val VALID_MASK = 0x00000001 or 0x00000002
+    val TRACKING_MASK = VALID_MASK or 0x00000004 or 0x00000008
+
+    require(flags or TRACKING_MASK == TRACKING_MASK) { "Invalid location flag bits." }
+
+    return when {
+        (flags and TRACKING_MASK) == TRACKING_MASK -> TrackingState.Tracking
+        (flags and VALID_MASK) == VALID_MASK -> TrackingState.Paused
+        else -> TrackingState.Stopped
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/ExportableAnchor.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/ExportableAnchor.kt
new file mode 100644
index 0000000..002f7f0
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/ExportableAnchor.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.os.IBinder
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+
+/** Wraps the minimum necessary information to export an anchor to another Jetpack XR module. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ExportableAnchor : Anchor {
+    /* nativePointer to the [XrSpace] instance that backs this anchor */
+    public val nativePointer: Long
+
+    /* anchorToken is a Binder reference of the anchor, it can be used to import the anchor by an
+     * OpenXR session. */
+    public val anchorToken: IBinder
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/HitData.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/HitData.kt
new file mode 100644
index 0000000..19227cb
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/HitData.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Data associated with a hit result.
+ *
+ * @property pose The pose of the hit result.
+ * @property id The id of the trackable that was hit. It is the address of the native object.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class HitData(val pose: Pose, val id: Long) {}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrAnchor.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrAnchor.kt
new file mode 100644
index 0000000..42bf15b
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrAnchor.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.os.IBinder
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import java.nio.ByteBuffer
+import java.util.UUID
+
+/** Wraps the native [XrSpace] with the [Anchor] interface. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrAnchor
+internal constructor(
+    override public val nativePointer: Long,
+    private val xrResources: XrResources,
+    loadedUuid: UUID? = null,
+) : ExportableAnchor, Updatable {
+
+    override public val anchorToken: IBinder by lazy { nativeGetAnchorToken(nativePointer) }
+
+    override var pose: Pose = Pose()
+        private set
+
+    override var trackingState: TrackingState = TrackingState.Paused
+        private set
+
+    override var persistenceState: Anchor.PersistenceState = Anchor.PersistenceState.NotPersisted
+        private set
+
+    override var uuid: UUID? = loadedUuid
+        private set
+
+    override fun persist() {
+        if (
+            persistenceState == Anchor.PersistenceState.Persisted ||
+                persistenceState == Anchor.PersistenceState.Pending
+        ) {
+            return
+        }
+        val uuidBytes =
+            checkNotNull(nativePersistAnchor(nativePointer)) { "Failed to persist anchor." }
+        UUIDFromByteArray(uuidBytes)?.let {
+            uuid = it
+            persistenceState = Anchor.PersistenceState.Pending
+        }
+    }
+
+    override fun detach() {
+        check(nativeDestroyAnchor(nativePointer)) { "Failed to destroy anchor." }
+        xrResources.removeUpdatable(this)
+    }
+
+    override fun update(xrTime: Long) {
+        val anchorState: AnchorState =
+            nativeGetAnchorState(nativePointer, xrTime)
+                ?: throw IllegalStateException(
+                    "Could not retrieve data for anchor. Is the anchor valid?"
+                )
+
+        trackingState = anchorState.trackingState
+        anchorState.pose?.let { pose = it }
+        if (uuid != null && persistenceState == Anchor.PersistenceState.Pending) {
+            persistenceState = nativeGetPersistenceState(uuid!!)
+        }
+    }
+
+    internal companion object {
+        internal fun UUIDFromByteArray(bytes: ByteArray?): UUID? {
+            if (bytes == null || bytes.size != 16) {
+                return null
+            }
+            val longBytes = ByteBuffer.wrap(bytes)
+            val mostSignificantBits = longBytes.long
+            val leastSignificantBits = longBytes.long
+            return UUID(mostSignificantBits, leastSignificantBits)
+        }
+    }
+
+    private external fun nativeGetAnchorState(nativePointer: Long, timestampNs: Long): AnchorState?
+
+    private external fun nativeGetAnchorToken(nativePointer: Long): IBinder
+
+    private external fun nativePersistAnchor(nativePointer: Long): ByteArray?
+
+    private external fun nativeGetPersistenceState(uuid: UUID): Anchor.PersistenceState
+
+    private external fun nativeDestroyAnchor(nativePointer: Long): Boolean
+}
+
+internal fun Anchor.PersistenceState.Companion.fromOpenXrPersistenceState(
+    value: Int
+): Anchor.PersistenceState =
+    when (value) {
+        0 ->
+            Anchor.PersistenceState
+                .NotPersisted // XR_ANCHOR_PERSIST_STATE_PERSIST_NOT_REQUESTED_ANDROID
+        1 -> Anchor.PersistenceState.Pending // XR_ANCHOR_PERSIST_STATE_PERSIST_PENDING_ANDROID
+        2 -> Anchor.PersistenceState.Persisted // XR_ANCHOR_PERSIST_STATE_PERSISTED_ANDROID
+        else -> {
+            throw IllegalArgumentException("Invalid persistence state value.")
+        }
+    }
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrManager.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrManager.kt
new file mode 100644
index 0000000..396bb6d
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrManager.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.LifecycleManager
+import kotlin.time.ComparableTimeMark
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+
+/** Manages the lifecycle of an OpenXR session. */
+@Suppress("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrManager
+internal constructor(
+    private val activity: Activity,
+    private val perceptionManager: OpenXrPerceptionManager,
+    internal val timeSource: OpenXrTimeSource,
+) : LifecycleManager {
+
+    /**
+     * A pointer to the native OpenXrManager. Only valid after [create] and before [stop] have been
+     * called.
+     */
+    internal var nativePointer: Long = 0L
+        private set
+
+    override fun create() {
+        nativePointer = nativeGetPointer()
+    }
+
+    override fun configure() {}
+
+    override fun resume() {
+        check(nativeInit(activity))
+    }
+
+    override suspend fun update(): ComparableTimeMark {
+        // TODO: b/345314364 - Implement this method properly once the native manager supports it.
+        // Currently the native manager handles this via an internal looping mechanism.
+        val now = timeSource.markNow()
+        perceptionManager.update(timeSource.getXrTime(now))
+        // Block the call for a time that is appropriate for OpenXR devices.
+        // TODO: b/359871229 - Implement dynamic delay. We start with a fixed 20ms delay as it is
+        // a nice round number that produces a reasonable frame rate @50 Hz, but this value may need
+        // to
+        // be adjusted in the future.
+        delay(20.milliseconds)
+        return now
+    }
+
+    override fun pause() {
+        check(nativePause())
+    }
+
+    override fun stop() {
+        nativeDeInit()
+        nativePointer = 0L
+        perceptionManager.clear()
+    }
+
+    private external fun nativeGetPointer(): Long
+
+    private external fun nativeInit(activity: Activity): Boolean
+
+    private external fun nativeDeInit(): Boolean
+
+    private external fun nativePause(): Boolean
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManager.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManager.kt
new file mode 100644
index 0000000..f390279
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPerceptionManager.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.HitResult
+import androidx.xr.runtime.internal.PerceptionManager
+import androidx.xr.runtime.internal.Plane
+import androidx.xr.runtime.internal.Trackable
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+import androidx.xr.runtime.math.Vector3
+import java.util.Arrays
+import java.util.UUID
+
+/** Implementation of the perception capabilities of a runtime using OpenXR. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrPerceptionManager
+internal constructor(private val timeSource: OpenXrTimeSource) : PerceptionManager {
+
+    override fun createAnchor(pose: Pose): Anchor {
+        val nativeAnchor = nativeCreateAnchor(pose, lastUpdateXrTime)
+        check(nativeAnchor != 0L) { "Failed to create anchor." }
+        val anchor = OpenXrAnchor(nativeAnchor, xrResources)
+        anchor.update(lastUpdateXrTime)
+        xrResources.addUpdatable(anchor as Updatable)
+        return anchor
+    }
+
+    // TODO: b/345315434 - Implement this method correctly once we have the ability to conduct
+    // hit tests in the native OpenXrManager.
+    override fun hitTest(ray: Ray): List<HitResult> {
+        val hitData =
+            nativeHitTest(
+                maxResults = 5,
+                ray.origin.x,
+                ray.origin.y,
+                ray.origin.z,
+                ray.direction.x,
+                ray.direction.y,
+                ray.direction.z,
+                lastUpdateXrTime,
+            )
+        return Arrays.asList(*hitData).toList().map { toHitResult(it, ray.origin) }
+    }
+
+    override fun getPersistedAnchorUuids(): List<UUID> {
+        val anchorUuids = nativeGetPersistedAnchorUuids()
+        return Arrays.asList(*anchorUuids)
+            .toList()
+            .map { OpenXrAnchor.UUIDFromByteArray(it) }
+            .filterNotNull()
+    }
+
+    override fun loadAnchor(uuid: UUID): Anchor {
+        val nativeAnchor = nativeLoadAnchor(uuid)
+        check(nativeAnchor != 0L) { "Failed to load anchor." }
+        val anchor = OpenXrAnchor(nativeAnchor, xrResources, loadedUuid = uuid)
+        anchor.update(lastUpdateXrTime)
+        xrResources.addUpdatable(anchor as Updatable)
+        return anchor
+    }
+
+    override fun loadAnchorFromNativePointer(nativePointer: Long): Anchor {
+        val anchor = OpenXrAnchor(nativePointer, xrResources)
+        anchor.update(lastUpdateXrTime)
+        xrResources.addUpdatable(anchor as Updatable)
+        return anchor
+    }
+
+    override fun unpersistAnchor(uuid: UUID) {
+        check(nativeUnpersistAnchor(uuid)) { "Failed to unpersist anchor." }
+    }
+
+    internal val xrResources = XrResources()
+    override val trackables: Collection<Trackable> = xrResources.trackablesMap.values
+
+    private var lastUpdateXrTime: Long = 0L
+
+    /**
+     * Updates the perception manager.
+     *
+     * @param xrTime the number of nanoseconds since the start of the OpenXR epoch.
+     */
+    public fun update(xrTime: Long) {
+        val planes = nativeGetPlanes()
+        // Add new planes to the list of trackables.
+        for (plane in planes) {
+            if (xrResources.trackablesMap.containsKey(plane)) continue
+
+            val planeTypeInt = nativeGetPlaneType(plane, xrTime)
+            check(planeTypeInt >= 0) { "Failed to get plane type." }
+
+            val trackable =
+                OpenXrPlane(plane, Plane.Type.fromOpenXrType(planeTypeInt), timeSource, xrResources)
+            xrResources.addTrackable(plane, trackable)
+            xrResources.addUpdatable(trackable as Updatable)
+        }
+
+        for (updatable in xrResources.updatables) {
+            updatable.update(xrTime)
+        }
+
+        lastUpdateXrTime = xrTime
+    }
+
+    internal fun clear() {
+        xrResources.clear()
+    }
+
+    private fun toHitResult(hitData: HitData, origin: Vector3): HitResult {
+        val trackable =
+            xrResources.trackablesMap[hitData.id]
+                ?: throw IllegalStateException("Trackable not found.")
+
+        return HitResult(
+            distance = (hitData.pose.translation - origin).length,
+            hitPose = hitData.pose,
+            trackable = trackable,
+        )
+    }
+
+    private external fun nativeCreateAnchor(pose: Pose, timestampNs: Long): Long
+
+    private external fun nativeGetPlanes(): LongArray
+
+    private external fun nativeGetPlaneType(planeId: Long, timestampNs: Long): Int
+
+    private external fun nativeHitTest(
+        maxResults: Int,
+        originX: Float,
+        originY: Float,
+        originZ: Float,
+        directionX: Float,
+        directionY: Float,
+        directionZ: Float,
+        timestampNs: Long,
+    ): Array<HitData>
+
+    private external fun nativeGetPersistedAnchorUuids(): Array<ByteArray>
+
+    private external fun nativeLoadAnchor(uuid: UUID): Long
+
+    private external fun nativeUnpersistAnchor(uuid: UUID): Boolean
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPlane.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPlane.kt
new file mode 100644
index 0000000..93c1e7b
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrPlane.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.Plane
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector2
+
+/** Wraps the native [XrTrackableANDROID] with the [Plane] interface. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrPlane
+internal constructor(
+    internal val planeId: Long,
+    override val type: Plane.Type,
+    internal val timeSource: OpenXrTimeSource,
+    private val xrResources: XrResources,
+) : Plane, Updatable {
+    override var label: Plane.Label = Plane.Label.Unknown
+        private set
+
+    override var centerPose: Pose = Pose()
+        private set
+
+    override var vertices: List<Vector2> = emptyList()
+        private set
+
+    override var extents: Vector2 = Vector2.Zero
+        private set
+
+    override var subsumedBy: Plane? = null
+        private set
+
+    override var trackingState: TrackingState = TrackingState.Paused
+        private set
+
+    override fun createAnchor(pose: Pose): Anchor {
+        val xrTime = timeSource.getXrTime(timeSource.markNow())
+        val anchorNativePointer = nativeCreateAnchorForPlane(planeId, pose, xrTime)
+        val anchor: Anchor = OpenXrAnchor(anchorNativePointer, xrResources)
+        xrResources.addUpdatable(anchor as Updatable)
+        return anchor
+    }
+
+    override fun update(xrTime: Long) {
+        val planeState: PlaneState =
+            nativeGetPlaneState(planeId, xrTime)
+                ?: throw IllegalStateException("Could latest plane state. Is the plane ID valid?")
+        label = planeState.label
+        trackingState = planeState.trackingState
+        centerPose = planeState.centerPose
+        extents = planeState.extents
+        vertices = planeState.vertices.toList()
+
+        if (planeState.subsumedByPlaneId != 0L) {
+            subsumedBy = xrResources.trackablesMap[planeState.subsumedByPlaneId] as OpenXrPlane?
+        }
+    }
+
+    private external fun nativeGetPlaneState(planeId: Long, timestampNs: Long): PlaneState?
+
+    private external fun nativeCreateAnchorForPlane(
+        planeId: Long,
+        pose: Pose,
+        timeStampNs: Long,
+    ): Long
+}
+
+/** Create a [Plane.Type] from an integer value corresponding to an [XrPlaneTypeANDROID]. */
+internal fun Plane.Type.Companion.fromOpenXrType(type: Int): Plane.Type =
+    when (type) {
+        0 -> Plane.Type.HorizontalDownwardFacing // XR_PLANE_TYPE_HORIZONTAL_DOWNWARD_FACING_ANDROID
+        1 -> Plane.Type.HorizontalUpwardFacing // XR_PLANE_TYPE_HORIZONTAL_UPWARD_FACING_ANDROID
+        2 -> Plane.Type.Vertical // XR_PLANE_TYPE_VERTICAL_ANDROID
+        else -> {
+            throw IllegalArgumentException("Invalid plane type.")
+        }
+    }
+
+/** Create a [Plane.Label] from an integer value corresponding to an [XrPlaneLabelANDROID]. */
+internal fun Plane.Label.Companion.fromOpenXrLabel(label: Int): Plane.Label =
+    when (label) {
+        0 -> Plane.Label.Unknown // XR_PLANE_LABEL_UNKNOWN_ANDROID
+        1 -> Plane.Label.Wall // XR_PLANE_LABEL_WALL_ANDROID
+        2 -> Plane.Label.Floor // XR_PLANE_LABEL_FLOOR_ANDROID
+        3 -> Plane.Label.Ceiling // XR_PLANE_LABEL_CEILING_ANDROID
+        4 -> Plane.Label.Table // XR_PLANE_LABEL_TABLE_ANDROID
+        else -> {
+            throw IllegalArgumentException("Invalid plane label.")
+        }
+    }
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntime.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntime.kt
new file mode 100644
index 0000000..28d43265
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntime.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Runtime
+
+/**
+ * Implementation of the [Runtime] interface using OpenXR.
+ *
+ * @property lifecycleManager that manages the lifecycle of the OpenXR session.
+ * @property perceptionManager that manages the perception capabilities of a runtime using OpenXR.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrRuntime
+internal constructor(
+    override val lifecycleManager: OpenXrManager,
+    override val perceptionManager: OpenXrPerceptionManager,
+) : Runtime {}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactory.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactory.kt
new file mode 100644
index 0000000..474cc9c
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrRuntimeFactory.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.app.Activity
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Runtime
+import androidx.xr.runtime.internal.RuntimeFactory
+
+/** Factory for creating instances of [OpenXrRuntime]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class OpenXrRuntimeFactory() : RuntimeFactory {
+    public companion object {
+        init {
+            try {
+                System.loadLibrary("androidx.xr.runtime.openxr")
+            } catch (e: UnsatisfiedLinkError) {
+                // TODO: b/344962771 - Use Flogger instead of println.
+                println("Failed to load library: $e")
+            }
+        }
+    }
+
+    override fun createRuntime(activity: Activity): Runtime {
+        val timeSource = OpenXrTimeSource()
+        val perceptionManager = OpenXrPerceptionManager(timeSource)
+        return OpenXrRuntime(
+            OpenXrManager(activity, perceptionManager, timeSource),
+            perceptionManager
+        )
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSource.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSource.kt
new file mode 100644
index 0000000..679ebe0
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/OpenXrTimeSource.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import kotlin.time.AbstractLongTimeSource
+import kotlin.time.ComparableTimeMark
+import kotlin.time.DurationUnit
+
+/** A time source that uses the native OpenXR time as the clock. */
+internal class OpenXrTimeSource : AbstractLongTimeSource(DurationUnit.NANOSECONDS) {
+    // Should be initialized when the base class initializes its [zero] field.
+    private var zeroXrTime: Long? = null
+    private lateinit var zeroTimeMark: ComparableTimeMark
+
+    override fun read(): Long {
+        val reading = nativeGetXrTimeNow()
+        zeroXrTime = zeroXrTime ?: reading
+        return reading
+    }
+
+    override fun markNow(): ComparableTimeMark {
+        val timeMark = super.markNow()
+        zeroTimeMark = if (::zeroTimeMark.isInitialized) zeroTimeMark else timeMark
+        return timeMark
+    }
+
+    /**
+     * Returns the XrTime corresponding to [timeMark]. This calculation is only valid if [timeMark]
+     * was created with this [TimeSource].
+     */
+    internal fun getXrTime(timeMark: ComparableTimeMark): Long {
+        check(zeroXrTime != null && ::zeroTimeMark.isInitialized)
+        val elapsedNanos = (timeMark - zeroTimeMark).inWholeNanoseconds
+        return zeroXrTime!! + elapsedNanos
+    }
+
+    private external fun nativeGetXrTimeNow(): Long
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/PlaneState.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/PlaneState.kt
new file mode 100644
index 0000000..d7bbcb1
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/PlaneState.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import androidx.xr.runtime.internal.Plane
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector2
+
+/**
+ * Represents the current state of a [Plane] instance's mutable fields.
+ *
+ * @property trackingState the [TrackingState] value describing if the plane is being updated.
+ * @property label the [Plane.Label] associated with the plane.
+ * @property centerPose the pose of the center of the detected plane. The pose's transformed +Y axis
+ *   will be point normal out of the plane, with the +X and +Z axes orienting the extents of the
+ *   bounding rectangle.
+ * @property extents the dimensions of the detected plane.
+ * @property vertices the 2D vertices of a convex polygon approximating the detected plane,
+ * @property subsumedByPlaneId the OpenXR handle of the plane that subsumed this plane.
+ */
+internal data class PlaneState(
+    val trackingState: TrackingState = TrackingState.Paused,
+    val label: Plane.Label = Plane.Label.Unknown,
+    val centerPose: Pose = Pose(),
+    val extents: Vector2 = Vector2.Zero,
+    val vertices: Array<Vector2> = emptyArray(),
+    val subsumedByPlaneId: Long = 0,
+) {}
+
+internal fun TrackingState.Companion.fromOpenXrTrackingState(trackingState: Int): TrackingState =
+    when (trackingState) {
+        0 -> TrackingState.Paused // XR_TRACKING_STATE_PAUSED_ANDROID
+        1 -> TrackingState.Stopped // XR_TRACKING_STATE_STOPPED_ANDROID
+        2 -> TrackingState.Tracking // XR_TRACKING_STATE_TRACKING_ANDROID
+        else -> {
+            throw IllegalArgumentException("Invalid tracking state.")
+        }
+    }
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/Updatable.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/Updatable.kt
new file mode 100644
index 0000000..3bc3505
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/Updatable.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+/** Describes an entity that can be updated across time. */
+internal interface Updatable {
+    /**
+     * Updates the entity retrieving its state at [xrTime].
+     *
+     * @param xrTime the number of nanoseconds since the start of the OpenXR epoch.
+     */
+    fun update(xrTime: Long)
+}
diff --git a/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/XrResources.kt b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/XrResources.kt
new file mode 100644
index 0000000..b485ce0
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/kotlin/androidx/xr/runtime/openxr/XrResources.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.openxr
+
+import android.annotation.SuppressLint
+import androidx.xr.runtime.internal.Trackable
+import java.util.concurrent.CopyOnWriteArrayList
+
+/** Object that holds resources that are used in the XR session. */
+internal class XrResources {
+    /** Map of native trackable pointer to [Trackable]. */
+    @SuppressLint("BanConcurrentHashMap")
+    private val _trackablesMap = java.util.concurrent.ConcurrentHashMap<Long, Trackable>()
+    val trackablesMap: Map<Long, Trackable> = _trackablesMap
+
+    /** List of [Updatable]s that are updated every frame. */
+    private val _updatables = CopyOnWriteArrayList<Updatable>()
+    val updatables: List<Updatable> = _updatables
+
+    internal fun addTrackable(trackableId: Long, trackable: Trackable) {
+        _trackablesMap[trackableId] = trackable
+    }
+
+    internal fun removeTrackable(trackableId: Long) {
+        _trackablesMap.remove(trackableId)
+    }
+
+    internal fun addUpdatable(updatable: Updatable) {
+        _updatables.add(updatable)
+    }
+
+    internal fun removeUpdatable(updatable: Updatable) {
+        _updatables.remove(updatable)
+    }
+
+    internal fun clear() {
+        _trackablesMap.clear()
+        _updatables.clear()
+    }
+}
diff --git a/xr/runtime/runtime-openxr/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory b/xr/runtime/runtime-openxr/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory
new file mode 100644
index 0000000..a780b6e
--- /dev/null
+++ b/xr/runtime/runtime-openxr/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory
@@ -0,0 +1 @@
+androidx.xr.runtime.openxr.OpenXrRuntimeFactory
\ No newline at end of file
diff --git a/xr/runtime/runtime-testing/api/current.txt b/xr/runtime/runtime-testing/api/current.txt
new file mode 100644
index 0000000..598558a
--- /dev/null
+++ b/xr/runtime/runtime-testing/api/current.txt
@@ -0,0 +1,14 @@
+// Signature format: 4.0
+package androidx.xr.runtime.testing.math {
+
+  public final class MathAssertions {
+    method public static void assertPose(androidx.xr.runtime.math.Pose actual, androidx.xr.runtime.math.Pose expected);
+    method public static void assertPose(androidx.xr.runtime.math.Pose actual, androidx.xr.runtime.math.Pose expected, optional float epsilon);
+    method public static void assertRotation(androidx.xr.runtime.math.Quaternion actual, androidx.xr.runtime.math.Quaternion expected);
+    method public static void assertRotation(androidx.xr.runtime.math.Quaternion actual, androidx.xr.runtime.math.Quaternion expected, optional float epsilon);
+    method public static void assertVector3(androidx.xr.runtime.math.Vector3 actual, androidx.xr.runtime.math.Vector3 expected);
+    method public static void assertVector3(androidx.xr.runtime.math.Vector3 actual, androidx.xr.runtime.math.Vector3 expected, optional float epsilon);
+  }
+
+}
+
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/runtime/runtime-testing/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/runtime/runtime-testing/api/res-current.txt
diff --git a/xr/runtime/runtime-testing/api/restricted_current.txt b/xr/runtime/runtime-testing/api/restricted_current.txt
new file mode 100644
index 0000000..531c744
--- /dev/null
+++ b/xr/runtime/runtime-testing/api/restricted_current.txt
@@ -0,0 +1,144 @@
+// Signature format: 4.0
+package androidx.xr.runtime.testing {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface AnchorHolder {
+    method public void detachAnchor(androidx.xr.runtime.internal.Anchor anchor);
+    method public void persistAnchor(androidx.xr.runtime.internal.Anchor anchor);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeLifecycleManager implements androidx.xr.runtime.internal.LifecycleManager {
+    ctor public FakeLifecycleManager();
+    method public void allowOneMoreCallToUpdate();
+    method public void configure();
+    method public void create();
+    method public androidx.xr.runtime.testing.FakeLifecycleManager.State getState();
+    method public kotlin.time.TestTimeSource getTimeSource();
+    method public void pause();
+    method public void resume();
+    method public void stop();
+    method public suspend Object? update(kotlin.coroutines.Continuation<? super kotlin.time.ComparableTimeMark>);
+    property public final androidx.xr.runtime.testing.FakeLifecycleManager.State state;
+    property public final kotlin.time.TestTimeSource timeSource;
+  }
+
+  public enum FakeLifecycleManager.State {
+    enum_constant public static final androidx.xr.runtime.testing.FakeLifecycleManager.State INITIALIZED;
+    enum_constant public static final androidx.xr.runtime.testing.FakeLifecycleManager.State NOT_INITIALIZED;
+    enum_constant public static final androidx.xr.runtime.testing.FakeLifecycleManager.State PAUSED;
+    enum_constant public static final androidx.xr.runtime.testing.FakeLifecycleManager.State RESUMED;
+    enum_constant public static final androidx.xr.runtime.testing.FakeLifecycleManager.State STOPPED;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakePerceptionManager implements androidx.xr.runtime.testing.AnchorHolder androidx.xr.runtime.internal.PerceptionManager {
+    ctor public FakePerceptionManager();
+    method public void addHitResult(androidx.xr.runtime.internal.HitResult hitResult);
+    method public void addTrackable(androidx.xr.runtime.internal.Trackable trackable);
+    method public void clearHitResults();
+    method public void clearTrackables();
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public void detachAnchor(androidx.xr.runtime.internal.Anchor anchor);
+    method public java.util.List<androidx.xr.runtime.internal.Anchor> getAnchors();
+    method public java.util.List<java.util.UUID> getPersistedAnchorUuids();
+    method public java.util.List<androidx.xr.runtime.internal.Trackable> getTrackables();
+    method public java.util.List<androidx.xr.runtime.internal.HitResult> hitTest(androidx.xr.runtime.math.Ray ray);
+    method public androidx.xr.runtime.internal.Anchor loadAnchor(java.util.UUID uuid);
+    method public androidx.xr.runtime.internal.Anchor loadAnchorFromNativePointer(long nativePointer);
+    method public void persistAnchor(androidx.xr.runtime.internal.Anchor anchor);
+    method public void unpersistAnchor(java.util.UUID uuid);
+    property public final java.util.List<androidx.xr.runtime.internal.Anchor> anchors;
+    property public java.util.List<androidx.xr.runtime.internal.Trackable> trackables;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeRuntime implements androidx.xr.runtime.internal.Runtime {
+    ctor public FakeRuntime(androidx.xr.runtime.testing.FakeLifecycleManager lifecycleManager, androidx.xr.runtime.testing.FakePerceptionManager perceptionManager);
+    method public androidx.xr.runtime.testing.FakeLifecycleManager component1();
+    method public androidx.xr.runtime.testing.FakePerceptionManager component2();
+    method public androidx.xr.runtime.testing.FakeRuntime copy(androidx.xr.runtime.testing.FakeLifecycleManager lifecycleManager, androidx.xr.runtime.testing.FakePerceptionManager perceptionManager);
+    method public androidx.xr.runtime.testing.FakeLifecycleManager getLifecycleManager();
+    method public androidx.xr.runtime.testing.FakePerceptionManager getPerceptionManager();
+    property public androidx.xr.runtime.testing.FakeLifecycleManager lifecycleManager;
+    property public androidx.xr.runtime.testing.FakePerceptionManager perceptionManager;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeRuntimeAnchor implements androidx.xr.runtime.internal.Anchor {
+    ctor public FakeRuntimeAnchor(androidx.xr.runtime.math.Pose pose, optional androidx.xr.runtime.testing.AnchorHolder? anchorHolder);
+    method public void detach();
+    method public androidx.xr.runtime.testing.AnchorHolder? getAnchorHolder();
+    method public androidx.xr.runtime.internal.Anchor.PersistenceState getPersistenceState();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public java.util.UUID? getUuid();
+    method public boolean isAttached();
+    method public void persist();
+    method public void setPersistenceState(androidx.xr.runtime.internal.Anchor.PersistenceState);
+    method public void setPose(androidx.xr.runtime.math.Pose);
+    method public void setTrackingState(androidx.xr.runtime.internal.TrackingState);
+    method public void setUuid(java.util.UUID?);
+    property public final androidx.xr.runtime.testing.AnchorHolder? anchorHolder;
+    property public final boolean isAttached;
+    property public androidx.xr.runtime.internal.Anchor.PersistenceState persistenceState;
+    property public androidx.xr.runtime.math.Pose pose;
+    property public androidx.xr.runtime.internal.TrackingState trackingState;
+    property public java.util.UUID? uuid;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeRuntimeFactory implements androidx.xr.runtime.internal.RuntimeFactory {
+    ctor public FakeRuntimeFactory();
+    method public androidx.xr.runtime.testing.FakeRuntime createRuntime(android.app.Activity activity);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeRuntimePlane implements androidx.xr.runtime.testing.AnchorHolder androidx.xr.runtime.internal.Plane {
+    ctor public FakeRuntimePlane();
+    ctor public FakeRuntimePlane(optional androidx.xr.runtime.internal.Plane.Type type, optional androidx.xr.runtime.internal.Plane.Label label, optional androidx.xr.runtime.internal.TrackingState trackingState, optional androidx.xr.runtime.math.Pose centerPose, optional androidx.xr.runtime.math.Vector2 extents, optional java.util.List<androidx.xr.runtime.math.Vector2> vertices, optional androidx.xr.runtime.internal.Plane? subsumedBy, optional java.util.Collection<androidx.xr.runtime.internal.Anchor> anchors);
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public void detachAnchor(androidx.xr.runtime.internal.Anchor anchor);
+    method public java.util.Collection<androidx.xr.runtime.internal.Anchor> getAnchors();
+    method public androidx.xr.runtime.math.Pose getCenterPose();
+    method public androidx.xr.runtime.math.Vector2 getExtents();
+    method public androidx.xr.runtime.internal.Plane.Label getLabel();
+    method public androidx.xr.runtime.internal.Plane? getSubsumedBy();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public androidx.xr.runtime.internal.Plane.Type getType();
+    method public java.util.List<androidx.xr.runtime.math.Vector2> getVertices();
+    method public void persistAnchor(androidx.xr.runtime.internal.Anchor anchor);
+    method public void setCenterPose(androidx.xr.runtime.math.Pose);
+    method public void setExtents(androidx.xr.runtime.math.Vector2);
+    method public void setSubsumedBy(androidx.xr.runtime.internal.Plane?);
+    method public void setTrackingState(androidx.xr.runtime.internal.TrackingState);
+    method public void setVertices(java.util.List<androidx.xr.runtime.math.Vector2>);
+    property public final java.util.Collection<androidx.xr.runtime.internal.Anchor> anchors;
+    property public androidx.xr.runtime.math.Pose centerPose;
+    property public androidx.xr.runtime.math.Vector2 extents;
+    property public androidx.xr.runtime.internal.Plane.Label label;
+    property public androidx.xr.runtime.internal.Plane? subsumedBy;
+    property public androidx.xr.runtime.internal.TrackingState trackingState;
+    property public androidx.xr.runtime.internal.Plane.Type type;
+    property public java.util.List<androidx.xr.runtime.math.Vector2> vertices;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class FakeStateExtender implements androidx.xr.runtime.StateExtender {
+    ctor public FakeStateExtender();
+    method public suspend Object? extend(androidx.xr.runtime.CoreState coreState, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public java.util.List<androidx.xr.runtime.CoreState> getExtended();
+    method public void initialize(androidx.xr.runtime.internal.Runtime runtime);
+    method public boolean isInitialized();
+    method public void setInitialized(boolean);
+    property public final java.util.List<androidx.xr.runtime.CoreState> extended;
+    property public final boolean isInitialized;
+  }
+
+}
+
+package androidx.xr.runtime.testing.math {
+
+  public final class MathAssertions {
+    method public static void assertPose(androidx.xr.runtime.math.Pose actual, androidx.xr.runtime.math.Pose expected);
+    method public static void assertPose(androidx.xr.runtime.math.Pose actual, androidx.xr.runtime.math.Pose expected, optional float epsilon);
+    method public static void assertRotation(androidx.xr.runtime.math.Quaternion actual, androidx.xr.runtime.math.Quaternion expected);
+    method public static void assertRotation(androidx.xr.runtime.math.Quaternion actual, androidx.xr.runtime.math.Quaternion expected, optional float epsilon);
+    method public static void assertVector3(androidx.xr.runtime.math.Vector3 actual, androidx.xr.runtime.math.Vector3 expected);
+    method public static void assertVector3(androidx.xr.runtime.math.Vector3 actual, androidx.xr.runtime.math.Vector3 expected, optional float epsilon);
+  }
+
+}
+
diff --git a/xr/runtime/runtime-testing/build.gradle b/xr/runtime/runtime-testing/build.gradle
new file mode 100644
index 0000000..4b037ce
--- /dev/null
+++ b/xr/runtime/runtime-testing/build.gradle
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
+    api(project(":xr:runtime:runtime"))
+
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation(libs.truth)
+
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(project(":kruth:kruth"))
+}
+
+android {
+    namespace = "androidx.xr.runtime.testing"
+    sourceSets.main.resources.srcDirs += "src/main/resources"
+}
+
+androidx {
+    name = "XR Fake Runtime"
+    type = LibraryType.PUBLISHED_TEST_LIBRARY
+    inceptionYear = "2024"
+    description = "A fake implementation of the XR runtime used in unit tests."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/AnchorHolder.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/AnchorHolder.kt
new file mode 100644
index 0000000..454d3f1
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/AnchorHolder.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+
+/** Object that holds [Anchor] instances. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface AnchorHolder {
+
+    /** Notifies the [AnchorHolder] that the given [Anchor] has been persisted. */
+    public fun persistAnchor(anchor: Anchor)
+
+    /**
+     * Detaches the given [Anchor] from this trackable. Single [Anchor] instances rely on this
+     * function to remove themselves from the [AnchorHolder].
+     */
+    public fun detachAnchor(anchor: Anchor)
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeLifecycleManager.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeLifecycleManager.kt
new file mode 100644
index 0000000..67bdac7
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeLifecycleManager.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.LifecycleManager
+import kotlin.time.ComparableTimeMark
+import kotlin.time.TestTimeSource
+import kotlinx.coroutines.sync.Semaphore
+
+/** Test-only implementation of [LifecycleManager] used to validate state transitions. */
+@Suppress("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeLifecycleManager : LifecycleManager {
+
+    /** Set of possible states of the runtime. */
+    public enum class State {
+        NOT_INITIALIZED,
+        INITIALIZED,
+        RESUMED,
+        PAUSED,
+        STOPPED,
+    }
+
+    /** The current state of the runtime. */
+    public var state: FakeLifecycleManager.State = State.NOT_INITIALIZED
+        private set
+
+    /** The time source used for this runtime. */
+    public val timeSource: TestTimeSource = TestTimeSource()
+
+    private val semaphore = Semaphore(1)
+
+    override fun create() {
+        check(state == State.NOT_INITIALIZED)
+        state = State.INITIALIZED
+    }
+
+    override fun configure() {
+        check(state == State.INITIALIZED || state == State.RESUMED || state == State.PAUSED)
+    }
+
+    override fun resume() {
+        check(state == State.INITIALIZED || state == State.PAUSED)
+        state = State.RESUMED
+    }
+
+    /**
+     * Retrieves the latest timemark. The first call to this method will execute immediately.
+     * Subsequent calls will be blocked until [allowOneMoreCallToUpdate] is called.
+     */
+    override suspend fun update(): ComparableTimeMark {
+        check(state == State.RESUMED)
+        semaphore.acquire()
+        return timeSource.markNow()
+    }
+
+    /**
+     * Allows an additional call to [update] to not be blocked. Requires that [update] has been
+     * called exactly once before each call to this method. Failure to do so will result in an
+     * [IllegalStateException].
+     */
+    public fun allowOneMoreCallToUpdate() {
+        semaphore.release()
+    }
+
+    override fun pause() {
+        check(state == State.RESUMED)
+        state = State.PAUSED
+    }
+
+    override fun stop() {
+        check(state == State.PAUSED || state == State.INITIALIZED)
+        state = State.STOPPED
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakePerceptionManager.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakePerceptionManager.kt
new file mode 100644
index 0000000..facb1fc
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakePerceptionManager.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.HitResult
+import androidx.xr.runtime.internal.PerceptionManager
+import androidx.xr.runtime.internal.Trackable
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+import java.util.UUID
+
+/** Test-only implementation of [PerceptionManager] used to validate state transitions. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakePerceptionManager : PerceptionManager, AnchorHolder {
+
+    public val anchors: MutableList<Anchor> = mutableListOf<Anchor>()
+    override val trackables: MutableList<Trackable> = mutableListOf<Trackable>()
+
+    private val hitResults = mutableListOf<HitResult>()
+    private val anchorUuids = mutableListOf<UUID>()
+
+    override fun createAnchor(pose: Pose): Anchor {
+        // TODO: b/349862231 - Modify it once detach is implemented.
+        val anchor = FakeRuntimeAnchor(pose, this)
+        anchors.add(anchor)
+        return anchor
+    }
+
+    override fun hitTest(ray: Ray): MutableList<HitResult> = hitResults
+
+    override fun getPersistedAnchorUuids(): List<UUID> = anchorUuids
+
+    override fun loadAnchor(uuid: UUID): Anchor {
+        check(anchorUuids.contains(uuid)) { "Anchor is not persisted." }
+        return FakeRuntimeAnchor(Pose(), this)
+    }
+
+    override fun unpersistAnchor(uuid: UUID) {
+        anchorUuids.remove(uuid)
+    }
+
+    override fun persistAnchor(anchor: Anchor) {
+        anchorUuids.add(anchor.uuid!!)
+    }
+
+    override fun loadAnchorFromNativePointer(nativePointer: Long): Anchor {
+        return FakeRuntimeAnchor(Pose(), this)
+    }
+
+    override fun detachAnchor(anchor: Anchor) {
+        anchors.remove(anchor)
+        anchor.uuid?.let { anchorUuids.remove(it) }
+    }
+
+    /** Adds a [HitResult] to the list that is returned when calling [hitTest] with any pose. */
+    public fun addHitResult(hitResult: HitResult) {
+        hitResults.add(hitResult)
+    }
+
+    /** Removes all [HitResult] instances passed to [addHitResult]. */
+    public fun clearHitResults() {
+        hitResults.clear()
+    }
+
+    /** Adds a [Trackable] to the list that is returned when calling [trackables]. */
+    public fun addTrackable(trackable: Trackable) {
+        trackables.add(trackable)
+    }
+
+    /** Removes all [Trackable] instances passed to [addTrackable]. */
+    public fun clearTrackables() {
+        trackables.clear()
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntime.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntime.kt
new file mode 100644
index 0000000..72770b3
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntime.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Runtime
+
+/** Test-only implementation of [Runtime] */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class FakeRuntime(
+    override val lifecycleManager: FakeLifecycleManager,
+    override val perceptionManager: FakePerceptionManager,
+) : Runtime {}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchor.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchor.kt
new file mode 100644
index 0000000..895330a
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchor.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor as RuntimeAnchor
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import java.util.UUID
+
+/** Test-only implementation of [RuntimeAnchor] */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeRuntimeAnchor(
+    override var pose: Pose,
+    public val anchorHolder: AnchorHolder? = null,
+) : RuntimeAnchor {
+    override var trackingState: TrackingState = TrackingState.Tracking
+
+    override var persistenceState: RuntimeAnchor.PersistenceState =
+        RuntimeAnchor.PersistenceState.NotPersisted
+
+    override var uuid: UUID? = null
+
+    /** Whether the anchor is attached to an [AnchorHolder] */
+    public var isAttached: Boolean = anchorHolder != null
+        private set
+
+    override fun persist() {
+        uuid = UUID.randomUUID()
+        persistenceState = RuntimeAnchor.PersistenceState.Persisted
+        anchorHolder?.persistAnchor(this)
+    }
+
+    override fun detach() {
+        if (anchorHolder != null) {
+            anchorHolder.detachAnchor(this)
+            isAttached = false
+        }
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactory.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactory.kt
new file mode 100644
index 0000000..823f7a8
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactory.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import android.app.Activity
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Runtime
+import androidx.xr.runtime.internal.RuntimeFactory
+
+/** Factory for creating test-only instances of [Runtime]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeRuntimeFactory() : RuntimeFactory {
+    override fun createRuntime(activity: Activity): FakeRuntime =
+        FakeRuntime(FakeLifecycleManager(), FakePerceptionManager())
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimePlane.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimePlane.kt
new file mode 100644
index 0000000..8252e2c
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeRuntimePlane.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Anchor as RuntimeAnchor
+import androidx.xr.runtime.internal.Plane as RuntimePlane
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector2
+
+/** Test-only implementation of [Plane] */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeRuntimePlane(
+    override val type: RuntimePlane.Type = RuntimePlane.Type.HorizontalUpwardFacing,
+    override val label: RuntimePlane.Label = RuntimePlane.Label.Floor,
+    override var trackingState: TrackingState = TrackingState.Tracking,
+    override var centerPose: Pose = Pose(),
+    override var extents: Vector2 = Vector2.Zero,
+    override var vertices: List<Vector2> = emptyList(),
+    override var subsumedBy: RuntimePlane? = null,
+    public val anchors: MutableCollection<RuntimeAnchor> = mutableListOf(),
+) : RuntimePlane, AnchorHolder {
+
+    override fun createAnchor(pose: Pose): RuntimeAnchor {
+        val anchor = FakeRuntimeAnchor(pose, this)
+        anchors.add(anchor)
+        return anchor
+    }
+
+    override fun detachAnchor(anchor: RuntimeAnchor) {
+        anchors.remove(anchor)
+    }
+
+    override fun persistAnchor(anchor: RuntimeAnchor) {}
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeStateExtender.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeStateExtender.kt
new file mode 100644
index 0000000..5b36ca1b
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/FakeStateExtender.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.CoreState
+import androidx.xr.runtime.StateExtender
+import androidx.xr.runtime.internal.Runtime
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeStateExtender() : StateExtender {
+
+    /** Whether the [StateExtender] has been initialized or not. */
+    public var isInitialized: Boolean = false
+
+    /** List of [CoreState] instances that have been extended. */
+    public val extended: MutableList<CoreState> = mutableListOf<CoreState>()
+
+    override fun initialize(runtime: Runtime) {
+        isInitialized = true
+    }
+
+    override suspend fun extend(coreState: CoreState) {
+        extended.add(coreState)
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/math/MathAssertions.kt b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/math/MathAssertions.kt
new file mode 100644
index 0000000..805d221
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/kotlin/androidx/xr/runtime/testing/math/MathAssertions.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("MathAssertions")
+
+package androidx.xr.runtime.testing.math
+
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+
+/**
+ * Asserts that two [Vector3]s are equal.
+ *
+ * @param actual the actual [Vector3] to compare
+ * @param expected the expected [Vector3] to compare
+ * @param epsilon the maximum difference allowed between the two [Vector3] objects
+ */
+@JvmOverloads
+public fun assertVector3(actual: Vector3, expected: Vector3, epsilon: Float = 1e-5f) {
+    assertThat(actual.x).isWithin(epsilon).of(expected.x)
+    assertThat(actual.y).isWithin(epsilon).of(expected.y)
+    assertThat(actual.z).isWithin(epsilon).of(expected.z)
+}
+
+/**
+ * Asserts that two [Quaternion]s are equal.
+ *
+ * @param actual the actual [Quaternion] to compare
+ * @param expected the expected [Quaternion] to compare
+ * @param epsilon the maximum difference allowed between the two [Quaternion] objects
+ */
+@JvmOverloads
+public fun assertRotation(actual: Quaternion, expected: Quaternion, epsilon: Float = 1e-5f) {
+    val dot = Math.abs(actual.toNormalized().dot(expected.toNormalized()))
+    assertThat(dot).isWithin(epsilon).of(1.0f)
+}
+
+/**
+ * Asserts that two [Pose]s are equal.
+ *
+ * @param actual the actual [Pose] to compare
+ * @param expected the expected [Pose] to compare
+ * @param epsilon the maximum difference allowed between the two [Pose] objects
+ */
+@JvmOverloads
+public fun assertPose(actual: Pose, expected: Pose, epsilon: Float = 1e-5f) {
+    assertVector3(actual.translation, expected.translation, epsilon)
+    assertRotation(actual.rotation, expected.rotation, epsilon)
+}
diff --git a/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender b/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender
new file mode 100644
index 0000000..4b3f581
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.StateExtender
@@ -0,0 +1 @@
+androidx.xr.runtime.testing.FakeStateExtender
diff --git a/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory b/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory
new file mode 100644
index 0000000..d7d1e9d
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/main/resources/META-INF/services/androidx.xr.runtime.internal.RuntimeFactory
@@ -0,0 +1 @@
+androidx.xr.runtime.testing.FakeRuntimeFactory
\ No newline at end of file
diff --git a/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeLifecycleManagerTest.kt b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeLifecycleManagerTest.kt
new file mode 100644
index 0000000..cebbd6e
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeLifecycleManagerTest.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FakeLifecycleManagerTest {
+
+    lateinit var underTest: FakeLifecycleManager
+
+    @Before
+    fun setUp() {
+        underTest = FakeLifecycleManager()
+    }
+
+    @Test
+    fun create_setsStateToInitialized() {
+        underTest.create()
+
+        assertThat(underTest.state).isEqualTo(FakeLifecycleManager.State.INITIALIZED)
+    }
+
+    @Test
+    fun create_calledTwice_throwsIllegalStateException() {
+        underTest.create()
+
+        assertFailsWith<IllegalStateException> { underTest.create() }
+    }
+
+    @Test
+    fun create_afterResume_throwsIllegalStateException() {
+        underTest.create()
+        underTest.resume()
+
+        assertFailsWith<IllegalStateException> { underTest.create() }
+    }
+
+    @Test
+    fun create_afterPause_throwsIllegalStateException() {
+        underTest.create()
+        underTest.resume()
+        underTest.pause()
+
+        assertFailsWith<IllegalStateException> { underTest.create() }
+    }
+
+    @Test
+    fun create_afterStop_throwsIllegalStateException() {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.create() }
+    }
+
+    @Test
+    fun configure_beforeCreate_throwsIllegalStateException() {
+        assertFailsWith<IllegalStateException> { underTest.configure() }
+    }
+
+    @Test
+    fun configure_afterStop_throwsIllegalStateException() {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.configure() }
+    }
+
+    @Test
+    fun resume_afterCreate_setsStateToResumed() {
+        underTest.create()
+
+        underTest.resume()
+
+        assertThat(underTest.state).isEqualTo(FakeLifecycleManager.State.RESUMED)
+    }
+
+    @Test
+    fun resume_calledTwice_throwsIllegalStateException() {
+        underTest.create()
+        underTest.resume()
+
+        assertFailsWith<IllegalStateException> { underTest.resume() }
+    }
+
+    @Test
+    fun resume_beforeCreate_throwsIllegalStateException() {
+        assertFailsWith<IllegalStateException> { underTest.resume() }
+    }
+
+    @Test
+    fun resume_afterStop_throwsIllegalStateException() {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.resume() }
+    }
+
+    @Test
+    fun update_beforeCreate_throwsIllegalStateException() = runTest {
+        assertFailsWith<IllegalStateException> { underTest.update() }
+    }
+
+    @Test
+    fun update_afterCreate_throwsIllegalStateException() = runTest {
+        underTest.create()
+
+        assertFailsWith<IllegalStateException> { underTest.update() }
+    }
+
+    @Test
+    fun update_afterPause_throwsIllegalStateException() = runTest {
+        underTest.create()
+        underTest.resume()
+        underTest.pause()
+
+        assertFailsWith<IllegalStateException> { underTest.update() }
+    }
+
+    @Test
+    fun update_afterStop_throwsIllegalStateException() = runTest {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.update() }
+    }
+
+    @Test
+    fun update_returnsTimeMarkFromTimeSource() = runTest {
+        val testDuration = 5.seconds
+        underTest.create()
+        underTest.resume()
+
+        val timeMark = underTest.update()
+        check(timeMark.elapsedNow().inWholeSeconds == 0L)
+        underTest.timeSource += testDuration
+
+        assertThat(timeMark.elapsedNow()).isEqualTo(testDuration)
+    }
+
+    @Test
+    fun update_calledTwiceAfterAllowOneMoreCallToUpdate_resumesExecution() = runTest {
+        val testDuration = 5.seconds
+        underTest.create()
+        underTest.resume()
+
+        val firstTimeMark = underTest.update()
+        underTest.timeSource += testDuration
+        underTest.allowOneMoreCallToUpdate()
+        val secondTimeMark = underTest.update()
+
+        assertThat(secondTimeMark - firstTimeMark).isEqualTo(testDuration)
+    }
+
+    @Test
+    fun pause_afterResume_setsStateToPaused() {
+        underTest.create()
+        underTest.resume()
+
+        underTest.pause()
+
+        assertThat(underTest.state).isEqualTo(FakeLifecycleManager.State.PAUSED)
+    }
+
+    @Test
+    fun pause_calledTwice_throwsIllegalStateException() {
+        underTest.create()
+        underTest.resume()
+        underTest.pause()
+
+        assertFailsWith<IllegalStateException> { underTest.pause() }
+    }
+
+    @Test
+    fun pause_beforeCreate_throwsIllegalStateException() {
+        assertFailsWith<IllegalStateException> { underTest.pause() }
+    }
+
+    @Test
+    fun pause_afterCreate_throwsIllegalStateException() {
+        underTest.create()
+
+        assertFailsWith<IllegalStateException> { underTest.pause() }
+    }
+
+    @Test
+    fun pause_afterStop_throwsIllegalStateException() {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.pause() }
+    }
+
+    @Test
+    fun stop_afterCreate_setsStateToStopped() {
+        underTest.create()
+
+        underTest.stop()
+
+        assertThat(underTest.state).isEqualTo(FakeLifecycleManager.State.STOPPED)
+    }
+
+    @Test
+    fun stop_afterPause_setsStateToStopped() {
+        underTest.create()
+        underTest.resume()
+        underTest.pause()
+
+        underTest.stop()
+
+        assertThat(underTest.state).isEqualTo(FakeLifecycleManager.State.STOPPED)
+    }
+
+    @Test
+    fun stop_calledTwice_throwsIllegalStateException() {
+        underTest.create()
+        underTest.stop()
+
+        assertFailsWith<IllegalStateException> { underTest.stop() }
+    }
+
+    @Test
+    fun stop_beforeCreate_throwsIllegalStateException() {
+        assertFailsWith<IllegalStateException> { underTest.stop() }
+    }
+
+    @Test
+    fun stop_afterResume_throwsIllegalStateException() {
+        underTest.create()
+        underTest.resume()
+
+        assertFailsWith<IllegalStateException> { underTest.stop() }
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakePerceptionManagerTest.kt b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakePerceptionManagerTest.kt
new file mode 100644
index 0000000..1cee2e0
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakePerceptionManagerTest.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.internal.HitResult
+import androidx.xr.runtime.internal.Trackable
+import androidx.xr.runtime.internal.TrackingState
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FakePerceptionManagerTest {
+
+    lateinit var underTest: FakePerceptionManager
+
+    @Before
+    fun setUp() {
+        underTest = FakePerceptionManager()
+    }
+
+    @Test
+    fun createAnchor_addsAnchorToAnchors() {
+        val anchor = underTest.createAnchor(Pose())
+
+        assertThat(underTest.anchors).containsExactly(anchor)
+    }
+
+    @Test
+    fun detachAnchor_removesAnchorFromAnchors() {
+        val anchor = underTest.createAnchor(Pose())
+        check(underTest.anchors.contains(anchor))
+
+        anchor.detach()
+
+        assertThat(underTest.anchors).isEmpty()
+    }
+
+    @Test
+    fun createAnchor_returnsAnchorWithTheGivenPose() {
+        val pose = Pose(translation = Vector3(1f, 2f, 3f))
+
+        val anchor = underTest.createAnchor(pose)
+
+        assertThat(anchor.pose).isEqualTo(pose)
+    }
+
+    @Test
+    fun createAnchor_returnsAnchorWithTrackingStateTracking() {
+        val anchor = underTest.createAnchor(Pose())
+
+        assertThat(anchor.trackingState).isEqualTo(TrackingState.Tracking)
+    }
+
+    @Test
+    fun detach_removesAnchorFromAnchors() {
+        val anchor = underTest.createAnchor(Pose())
+        check(underTest.anchors.contains(anchor))
+
+        anchor.detach()
+
+        assertThat(underTest.anchors).isEmpty()
+    }
+
+    @Test
+    fun hitTest_returnsAddedHitResult() {
+        val ray = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val hitResult = HitResult(distance = 1f, Pose(), createStubTrackable())
+        underTest.addHitResult(hitResult)
+
+        assertThat(underTest.hitTest(ray)).containsExactly(hitResult)
+    }
+
+    @Test
+    fun clearHitResults_removesAllHitResults() {
+        val ray = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val hitResult = HitResult(distance = 1f, Pose(), createStubTrackable())
+        underTest.addHitResult(hitResult)
+
+        underTest.clearHitResults()
+
+        assertThat(underTest.hitTest(ray)).isEmpty()
+    }
+
+    @Test
+    fun addTrackable_addsTrackableToTrackables() {
+        val trackable = createStubTrackable()
+
+        underTest.addTrackable(trackable)
+
+        assertThat(underTest.trackables).containsExactly(trackable)
+    }
+
+    @Test
+    fun clearTrackables_removesAllTrackables() {
+        val trackable = createStubTrackable()
+        underTest.addTrackable(trackable)
+
+        underTest.clearTrackables()
+
+        assertThat(underTest.trackables).isEmpty()
+    }
+
+    @Test
+    fun getPersistedAnchorUuids_returnsOneUuid() {
+        val anchor = underTest.createAnchor(Pose())
+        anchor.persist()
+
+        assertThat(underTest.getPersistedAnchorUuids()).containsExactly(anchor.uuid!!)
+    }
+
+    @Test
+    fun loadAnchor_returnsNewAnchor() {
+        val persistedAnchor = underTest.createAnchor(Pose())
+        persistedAnchor.persist()
+
+        val loadedAnchor = underTest.loadAnchor(persistedAnchor.uuid!!)
+
+        assertThat(loadedAnchor.pose).isEqualTo(Pose())
+    }
+
+    @Test
+    fun unpersistAnchor_removesAnchorFromAnchorUuids() {
+        val anchor = underTest.createAnchor(Pose())
+        anchor.persist()
+        check(underTest.getPersistedAnchorUuids().contains(anchor.uuid!!))
+
+        underTest.unpersistAnchor(anchor.uuid!!)
+
+        assertThat(underTest.getPersistedAnchorUuids()).isEmpty()
+    }
+
+    private fun createStubTrackable() =
+        object : Trackable, AnchorHolder {
+            override fun createAnchor(pose: Pose): Anchor = underTest.createAnchor(pose)
+
+            override fun detachAnchor(anchor: Anchor) {
+                underTest.detachAnchor(anchor)
+            }
+
+            override fun persistAnchor(anchor: Anchor) {
+                underTest.persistAnchor(anchor)
+            }
+
+            override val trackingState = TrackingState.Tracking
+        }
+}
diff --git a/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchorTest.kt b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchorTest.kt
new file mode 100644
index 0000000..bdf1cfc
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeAnchorTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.xr.runtime.internal.Anchor
+import androidx.xr.runtime.math.Pose
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FakeRuntimeAnchorTest {
+
+    @Test
+    fun constructor_anchorHolderNotNull_isAttached() {
+        val underTest = FakeRuntimeAnchor(Pose(), FakeRuntimePlane())
+
+        assertThat(underTest.isAttached).isTrue()
+    }
+
+    @Test
+    fun constructor_anchorHolderNull_isNotAttached() {
+        val underTest = FakeRuntimeAnchor(Pose())
+
+        assertThat(underTest.isAttached).isFalse()
+    }
+
+    @Test
+    fun persist_setsUuidToRandomValueAndPersistenceStateToPersisted() {
+        val underTest = FakeRuntimeAnchor(Pose())
+        check(underTest.uuid == null)
+        check(underTest.persistenceState == Anchor.PersistenceState.NotPersisted)
+
+        underTest.persist()
+
+        assertThat(underTest.uuid).isNotNull()
+        assertThat(underTest.persistenceState).isEqualTo(Anchor.PersistenceState.Persisted)
+    }
+
+    @Test
+    fun detach_attachedBecomesFalse() {
+        val underTest = FakeRuntimeAnchor(Pose(), FakeRuntimePlane())
+        check(underTest.isAttached.equals(true))
+
+        underTest.detach()
+
+        assertThat(underTest.isAttached).isFalse()
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactoryTest.kt b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactoryTest.kt
new file mode 100644
index 0000000..23e6c9b
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeRuntimeFactoryTest.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import android.app.Activity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.internal.RuntimeFactory
+import com.google.common.truth.Truth.assertThat
+import java.util.ServiceLoader
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FakeRuntimeFactoryTest {
+
+    @Test
+    fun class_isDiscoverableViaServiceLoader() {
+        assertThat(ServiceLoader.load(RuntimeFactory::class.java).iterator().next())
+            .isInstanceOf(FakeRuntimeFactory::class.java)
+    }
+
+    @Test
+    fun createRuntime_createsFakeRuntime() {
+        val underTest = FakeRuntimeFactory()
+
+        assertThat(underTest.createRuntime(Activity())).isInstanceOf(FakeRuntime::class.java)
+    }
+}
diff --git a/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeStateExtenderTest.kt b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeStateExtenderTest.kt
new file mode 100644
index 0000000..8998c0b
--- /dev/null
+++ b/xr/runtime/runtime-testing/src/test/kotlin/androidx/xr/runtime/testing/FakeStateExtenderTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.testing
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.CoreState
+import androidx.xr.runtime.StateExtender
+import com.google.common.truth.Truth.assertThat
+import java.util.ServiceLoader
+import kotlin.time.ComparableTimeMark
+import kotlin.time.TestTimeSource
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FakeStateExtenderTest {
+
+    lateinit var underTest: FakeStateExtender
+
+    @Before
+    fun setUp() {
+        underTest = FakeStateExtender()
+    }
+
+    @Test
+    fun class_isDiscoverableViaServiceLoader() {
+        assertThat(ServiceLoader.load(StateExtender::class.java).iterator().next())
+            .isInstanceOf(FakeStateExtender::class.java)
+    }
+
+    @Test
+    fun initialize_setsInitializedToTrue() {
+        check(underTest.isInitialized == false)
+
+        underTest.initialize(FakeRuntime(FakeLifecycleManager(), FakePerceptionManager()))
+
+        assertThat(underTest.isInitialized).isTrue()
+    }
+
+    @Test
+    fun extend_addsStateToExtended(): Unit = runBlocking {
+        check(underTest.extended.isEmpty())
+        val state = createStubCoreState(TestTimeSource().markNow())
+
+        underTest.extend(state)
+
+        assertThat(underTest.extended).containsExactly(state)
+    }
+
+    private fun createStubCoreState(timeMark: ComparableTimeMark): CoreState {
+        return CoreState(timeMark)
+    }
+}
diff --git a/xr/runtime/runtime/api/current.txt b/xr/runtime/runtime/api/current.txt
new file mode 100644
index 0000000..e89eee5
--- /dev/null
+++ b/xr/runtime/runtime/api/current.txt
@@ -0,0 +1,297 @@
+// Signature format: 4.0
+package androidx.xr.runtime.math {
+
+  public final class MathHelper {
+    method public static float clamp(float x, float min, float max);
+    method public static float lerp(float a, float b, float t);
+    method public static float toDegrees(float angleInRadians);
+    method public static float toRadians(float angleInDegrees);
+  }
+
+  public final class Matrix4 {
+    ctor public Matrix4(androidx.xr.runtime.math.Matrix4 other);
+    ctor public Matrix4(float[] dataToCopy);
+    method public androidx.xr.runtime.math.Matrix4 copy(optional float[] data);
+    method public static androidx.xr.runtime.math.Matrix4 fromPose(androidx.xr.runtime.math.Pose pose);
+    method public static androidx.xr.runtime.math.Matrix4 fromQuaternion(androidx.xr.runtime.math.Quaternion quaternion);
+    method public static androidx.xr.runtime.math.Matrix4 fromScale(androidx.xr.runtime.math.Vector3 scale);
+    method public static androidx.xr.runtime.math.Matrix4 fromScale(float scale);
+    method public static androidx.xr.runtime.math.Matrix4 fromTranslation(androidx.xr.runtime.math.Vector3 translation);
+    method public static androidx.xr.runtime.math.Matrix4 fromTrs(androidx.xr.runtime.math.Vector3 translation, androidx.xr.runtime.math.Quaternion rotation, androidx.xr.runtime.math.Vector3 scale);
+    method public float[] getData();
+    method public androidx.xr.runtime.math.Matrix4 getInverse();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.math.Quaternion getRotation();
+    method public androidx.xr.runtime.math.Vector3 getScale();
+    method public androidx.xr.runtime.math.Vector3 getTranslation();
+    method public androidx.xr.runtime.math.Matrix4 getTranspose();
+    method public boolean isTrs();
+    method public operator androidx.xr.runtime.math.Matrix4 times(androidx.xr.runtime.math.Matrix4 other);
+    property public final float[] data;
+    property public final androidx.xr.runtime.math.Matrix4 inverse;
+    property public final boolean isTrs;
+    property public final androidx.xr.runtime.math.Pose pose;
+    property public final androidx.xr.runtime.math.Quaternion rotation;
+    property public final androidx.xr.runtime.math.Vector3 scale;
+    property public final androidx.xr.runtime.math.Vector3 translation;
+    property public final androidx.xr.runtime.math.Matrix4 transpose;
+    field public static final androidx.xr.runtime.math.Matrix4.Companion Companion;
+    field public static final androidx.xr.runtime.math.Matrix4 Identity;
+    field public static final androidx.xr.runtime.math.Matrix4 Zero;
+  }
+
+  public static final class Matrix4.Companion {
+    method public androidx.xr.runtime.math.Matrix4 fromPose(androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.runtime.math.Matrix4 fromQuaternion(androidx.xr.runtime.math.Quaternion quaternion);
+    method public androidx.xr.runtime.math.Matrix4 fromScale(androidx.xr.runtime.math.Vector3 scale);
+    method public androidx.xr.runtime.math.Matrix4 fromScale(float scale);
+    method public androidx.xr.runtime.math.Matrix4 fromTranslation(androidx.xr.runtime.math.Vector3 translation);
+    method public androidx.xr.runtime.math.Matrix4 fromTrs(androidx.xr.runtime.math.Vector3 translation, androidx.xr.runtime.math.Quaternion rotation, androidx.xr.runtime.math.Vector3 scale);
+    property public final androidx.xr.runtime.math.Matrix4 Identity;
+    property public final androidx.xr.runtime.math.Matrix4 Zero;
+  }
+
+  public final class Pose {
+    ctor public Pose();
+    ctor public Pose(androidx.xr.runtime.math.Pose other);
+    ctor public Pose(optional androidx.xr.runtime.math.Vector3 translation);
+    ctor public Pose(optional androidx.xr.runtime.math.Vector3 translation, optional androidx.xr.runtime.math.Quaternion rotation);
+    method public infix androidx.xr.runtime.math.Pose compose(androidx.xr.runtime.math.Pose other);
+    method public androidx.xr.runtime.math.Pose copy();
+    method public androidx.xr.runtime.math.Pose copy(optional androidx.xr.runtime.math.Vector3 translation);
+    method public androidx.xr.runtime.math.Pose copy(optional androidx.xr.runtime.math.Vector3 translation, optional androidx.xr.runtime.math.Quaternion rotation);
+    method public static float distance(androidx.xr.runtime.math.Pose lhs, androidx.xr.runtime.math.Pose rhs);
+    method public static androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target);
+    method public static androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target, optional androidx.xr.runtime.math.Vector3 up);
+    method public inline androidx.xr.runtime.math.Vector3 getBackward();
+    method public inline androidx.xr.runtime.math.Vector3 getDown();
+    method public inline androidx.xr.runtime.math.Vector3 getForward();
+    method public androidx.xr.runtime.math.Pose getInverse();
+    method public inline androidx.xr.runtime.math.Vector3 getLeft();
+    method public inline androidx.xr.runtime.math.Vector3 getRight();
+    method public androidx.xr.runtime.math.Quaternion getRotation();
+    method public androidx.xr.runtime.math.Vector3 getTranslation();
+    method public inline androidx.xr.runtime.math.Vector3 getUp();
+    method public static androidx.xr.runtime.math.Pose lerp(androidx.xr.runtime.math.Pose start, androidx.xr.runtime.math.Pose end, float ratio);
+    method public androidx.xr.runtime.math.Pose rotate(androidx.xr.runtime.math.Quaternion rotation);
+    method public infix androidx.xr.runtime.math.Vector3 transformPoint(androidx.xr.runtime.math.Vector3 point);
+    method public infix androidx.xr.runtime.math.Vector3 transformVector(androidx.xr.runtime.math.Vector3 vector);
+    method public androidx.xr.runtime.math.Pose translate(androidx.xr.runtime.math.Vector3 translation);
+    property public final inline androidx.xr.runtime.math.Vector3 backward;
+    property public final inline androidx.xr.runtime.math.Vector3 down;
+    property public final inline androidx.xr.runtime.math.Vector3 forward;
+    property public final androidx.xr.runtime.math.Pose inverse;
+    property public final inline androidx.xr.runtime.math.Vector3 left;
+    property public final inline androidx.xr.runtime.math.Vector3 right;
+    property public final androidx.xr.runtime.math.Quaternion rotation;
+    property public final androidx.xr.runtime.math.Vector3 translation;
+    property public final inline androidx.xr.runtime.math.Vector3 up;
+    field public static final androidx.xr.runtime.math.Pose.Companion Companion;
+    field public static final androidx.xr.runtime.math.Pose Identity;
+  }
+
+  public static final class Pose.Companion {
+    method public float distance(androidx.xr.runtime.math.Pose lhs, androidx.xr.runtime.math.Pose rhs);
+    method public androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target);
+    method public androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target, optional androidx.xr.runtime.math.Vector3 up);
+    method public androidx.xr.runtime.math.Pose lerp(androidx.xr.runtime.math.Pose start, androidx.xr.runtime.math.Pose end, float ratio);
+    property public final androidx.xr.runtime.math.Pose Identity;
+  }
+
+  public final class Quaternion {
+    ctor public Quaternion();
+    ctor public Quaternion(androidx.xr.runtime.math.Quaternion other);
+    ctor public Quaternion(optional float x);
+    ctor public Quaternion(optional float x, optional float y);
+    ctor public Quaternion(optional float x, optional float y, optional float z);
+    ctor public Quaternion(optional float x, optional float y, optional float z, optional float w);
+    method public static float angle(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public androidx.xr.runtime.math.Quaternion copy();
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y, optional float z);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y, optional float z, optional float w);
+    method public operator androidx.xr.runtime.math.Quaternion div(float c);
+    method public inline infix float dot(androidx.xr.runtime.math.Quaternion other);
+    method public static float dot(androidx.xr.runtime.math.Quaternion lhs, androidx.xr.runtime.math.Quaternion rhs);
+    method public static androidx.xr.runtime.math.Quaternion fromAxisAngle(androidx.xr.runtime.math.Vector3 axis, float degrees);
+    method public static androidx.xr.runtime.math.Quaternion fromEulerAngles(androidx.xr.runtime.math.Vector3 eulerAngles);
+    method public static androidx.xr.runtime.math.Quaternion fromEulerAngles(float pitch, float yaw, float roll);
+    method public static androidx.xr.runtime.math.Quaternion fromLookTowards(androidx.xr.runtime.math.Vector3 forward, androidx.xr.runtime.math.Vector3 up);
+    method public static androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public static androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end);
+    method public kotlin.Pair<androidx.xr.runtime.math.Vector3,java.lang.Float> getAxisAngle();
+    method public androidx.xr.runtime.math.Vector3 getEulerAngles();
+    method public inline androidx.xr.runtime.math.Quaternion getInverse();
+    method public float getW();
+    method public float getX();
+    method public float getY();
+    method public float getZ();
+    method public static androidx.xr.runtime.math.Quaternion lerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Quaternion minus(androidx.xr.runtime.math.Quaternion other);
+    method public inline operator androidx.xr.runtime.math.Quaternion plus(androidx.xr.runtime.math.Quaternion other);
+    method public static androidx.xr.runtime.math.Quaternion slerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Quaternion times(androidx.xr.runtime.math.Quaternion other);
+    method public inline operator androidx.xr.runtime.math.Vector3 times(androidx.xr.runtime.math.Vector3 src);
+    method public operator androidx.xr.runtime.math.Quaternion times(float c);
+    method public androidx.xr.runtime.math.Quaternion toNormalized();
+    method public inline operator androidx.xr.runtime.math.Quaternion unaryMinus();
+    property public final kotlin.Pair<androidx.xr.runtime.math.Vector3,java.lang.Float> axisAngle;
+    property public final androidx.xr.runtime.math.Vector3 eulerAngles;
+    property public final inline androidx.xr.runtime.math.Quaternion inverse;
+    property public final float w;
+    property public final float x;
+    property public final float y;
+    property public final float z;
+    field public static final androidx.xr.runtime.math.Quaternion.Companion Companion;
+    field public static final androidx.xr.runtime.math.Quaternion Identity;
+  }
+
+  public static final class Quaternion.Companion {
+    method public float angle(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public float dot(androidx.xr.runtime.math.Quaternion lhs, androidx.xr.runtime.math.Quaternion rhs);
+    method public androidx.xr.runtime.math.Quaternion fromAxisAngle(androidx.xr.runtime.math.Vector3 axis, float degrees);
+    method public androidx.xr.runtime.math.Quaternion fromEulerAngles(androidx.xr.runtime.math.Vector3 eulerAngles);
+    method public androidx.xr.runtime.math.Quaternion fromEulerAngles(float pitch, float yaw, float roll);
+    method public androidx.xr.runtime.math.Quaternion fromLookTowards(androidx.xr.runtime.math.Vector3 forward, androidx.xr.runtime.math.Vector3 up);
+    method public androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end);
+    method public androidx.xr.runtime.math.Quaternion lerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public androidx.xr.runtime.math.Quaternion slerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    property public final androidx.xr.runtime.math.Quaternion Identity;
+  }
+
+  public final class Ray {
+    ctor public Ray();
+    ctor public Ray(androidx.xr.runtime.math.Ray other);
+    ctor public Ray(optional androidx.xr.runtime.math.Vector3 origin, optional androidx.xr.runtime.math.Vector3 direction);
+    method public androidx.xr.runtime.math.Vector3 getDirection();
+    method public androidx.xr.runtime.math.Vector3 getOrigin();
+    property public final androidx.xr.runtime.math.Vector3 direction;
+    property public final androidx.xr.runtime.math.Vector3 origin;
+  }
+
+  public final class Vector2 {
+    ctor public Vector2();
+    ctor public Vector2(androidx.xr.runtime.math.Vector2 other);
+    ctor public Vector2(optional float x);
+    ctor public Vector2(optional float x, optional float y);
+    method public static androidx.xr.runtime.math.Vector2 abs(androidx.xr.runtime.math.Vector2 vector);
+    method public static float angularDistance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public androidx.xr.runtime.math.Vector2 clamp(androidx.xr.runtime.math.Vector2 min, androidx.xr.runtime.math.Vector2 max);
+    method public inline androidx.xr.runtime.math.Vector2 copy();
+    method public inline androidx.xr.runtime.math.Vector2 copy(optional float x);
+    method public inline androidx.xr.runtime.math.Vector2 copy(optional float x, optional float y);
+    method public inline infix float cross(androidx.xr.runtime.math.Vector2 other);
+    method public static float distance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public inline operator androidx.xr.runtime.math.Vector2 div(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 div(float c);
+    method public inline infix float dot(androidx.xr.runtime.math.Vector2 other);
+    method public inline float getLength();
+    method public inline float getLengthSquared();
+    method public float getX();
+    method public float getY();
+    method public static androidx.xr.runtime.math.Vector2 lerp(androidx.xr.runtime.math.Vector2 start, androidx.xr.runtime.math.Vector2 end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Vector2 minus(androidx.xr.runtime.math.Vector2 other);
+    method public operator androidx.xr.runtime.math.Vector2 plus(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 times(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 times(float c);
+    method public androidx.xr.runtime.math.Vector2 toNormalized();
+    method public inline operator androidx.xr.runtime.math.Vector2 unaryMinus();
+    property public final inline float length;
+    property public final inline float lengthSquared;
+    property public final float x;
+    property public final float y;
+    field public static final androidx.xr.runtime.math.Vector2.Companion Companion;
+    field public static final androidx.xr.runtime.math.Vector2 Down;
+    field public static final androidx.xr.runtime.math.Vector2 Left;
+    field public static final androidx.xr.runtime.math.Vector2 One;
+    field public static final androidx.xr.runtime.math.Vector2 Right;
+    field public static final androidx.xr.runtime.math.Vector2 Up;
+    field public static final androidx.xr.runtime.math.Vector2 Zero;
+  }
+
+  public static final class Vector2.Companion {
+    method public androidx.xr.runtime.math.Vector2 abs(androidx.xr.runtime.math.Vector2 vector);
+    method public float angularDistance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public float distance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public androidx.xr.runtime.math.Vector2 lerp(androidx.xr.runtime.math.Vector2 start, androidx.xr.runtime.math.Vector2 end, float ratio);
+    property public final androidx.xr.runtime.math.Vector2 Down;
+    property public final androidx.xr.runtime.math.Vector2 Left;
+    property public final androidx.xr.runtime.math.Vector2 One;
+    property public final androidx.xr.runtime.math.Vector2 Right;
+    property public final androidx.xr.runtime.math.Vector2 Up;
+    property public final androidx.xr.runtime.math.Vector2 Zero;
+  }
+
+  public final class Vector3 {
+    ctor public Vector3();
+    ctor public Vector3(androidx.xr.runtime.math.Vector3 other);
+    ctor public Vector3(optional float x);
+    ctor public Vector3(optional float x, optional float y);
+    ctor public Vector3(optional float x, optional float y, optional float z);
+    method public static androidx.xr.runtime.math.Vector3 abs(androidx.xr.runtime.math.Vector3 vector);
+    method public static float angleBetween(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public androidx.xr.runtime.math.Vector3 clamp(androidx.xr.runtime.math.Vector3 min, androidx.xr.runtime.math.Vector3 max);
+    method public androidx.xr.runtime.math.Vector3 copy();
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x);
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x, optional float y);
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x, optional float y, optional float z);
+    method public infix androidx.xr.runtime.math.Vector3 cross(androidx.xr.runtime.math.Vector3 other);
+    method public static float distance(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public operator androidx.xr.runtime.math.Vector3 div(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 div(float c);
+    method public infix float dot(androidx.xr.runtime.math.Vector3 other);
+    method public static androidx.xr.runtime.math.Vector3 fromValue(float value);
+    method public inline float getLength();
+    method public inline float getLengthSquared();
+    method public float getX();
+    method public float getY();
+    method public float getZ();
+    method public static androidx.xr.runtime.math.Vector3 lerp(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end, float ratio);
+    method public static androidx.xr.runtime.math.Vector3 max(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public static androidx.xr.runtime.math.Vector3 min(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public operator androidx.xr.runtime.math.Vector3 minus(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 plus(androidx.xr.runtime.math.Vector3 other);
+    method public static androidx.xr.runtime.math.Vector3 projectOnPlane(androidx.xr.runtime.math.Vector3 vector, androidx.xr.runtime.math.Vector3 planeNormal);
+    method public operator androidx.xr.runtime.math.Vector3 times(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 times(float c);
+    method public androidx.xr.runtime.math.Vector3 toNormalized();
+    method public operator androidx.xr.runtime.math.Vector3 unaryMinus();
+    property public final inline float length;
+    property public final inline float lengthSquared;
+    property public final float x;
+    property public final float y;
+    property public final float z;
+    field public static final androidx.xr.runtime.math.Vector3 Backward;
+    field public static final androidx.xr.runtime.math.Vector3.Companion Companion;
+    field public static final androidx.xr.runtime.math.Vector3 Down;
+    field public static final androidx.xr.runtime.math.Vector3 Forward;
+    field public static final androidx.xr.runtime.math.Vector3 Left;
+    field public static final androidx.xr.runtime.math.Vector3 One;
+    field public static final androidx.xr.runtime.math.Vector3 Right;
+    field public static final androidx.xr.runtime.math.Vector3 Up;
+    field public static final androidx.xr.runtime.math.Vector3 Zero;
+  }
+
+  public static final class Vector3.Companion {
+    method public androidx.xr.runtime.math.Vector3 abs(androidx.xr.runtime.math.Vector3 vector);
+    method public float angleBetween(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public float distance(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public androidx.xr.runtime.math.Vector3 fromValue(float value);
+    method public androidx.xr.runtime.math.Vector3 lerp(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end, float ratio);
+    method public androidx.xr.runtime.math.Vector3 max(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public androidx.xr.runtime.math.Vector3 min(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public androidx.xr.runtime.math.Vector3 projectOnPlane(androidx.xr.runtime.math.Vector3 vector, androidx.xr.runtime.math.Vector3 planeNormal);
+    property public final androidx.xr.runtime.math.Vector3 Backward;
+    property public final androidx.xr.runtime.math.Vector3 Down;
+    property public final androidx.xr.runtime.math.Vector3 Forward;
+    property public final androidx.xr.runtime.math.Vector3 Left;
+    property public final androidx.xr.runtime.math.Vector3 One;
+    property public final androidx.xr.runtime.math.Vector3 Right;
+    property public final androidx.xr.runtime.math.Vector3 Up;
+    property public final androidx.xr.runtime.math.Vector3 Zero;
+  }
+
+}
+
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/runtime/runtime/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/runtime/runtime/api/res-current.txt
diff --git a/xr/runtime/runtime/api/restricted_current.txt b/xr/runtime/runtime/api/restricted_current.txt
new file mode 100644
index 0000000..333830a
--- /dev/null
+++ b/xr/runtime/runtime/api/restricted_current.txt
@@ -0,0 +1,537 @@
+// Signature format: 4.0
+package androidx.xr.runtime {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class CoreState {
+    ctor public CoreState(kotlin.time.ComparableTimeMark timeMark);
+    method public kotlin.time.ComparableTimeMark component1();
+    method public androidx.xr.runtime.CoreState copy(kotlin.time.ComparableTimeMark timeMark);
+    method public kotlin.time.ComparableTimeMark getTimeMark();
+    property public final kotlin.time.ComparableTimeMark timeMark;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Session {
+    method public androidx.xr.runtime.SessionConfigureResult configure();
+    method public static androidx.xr.runtime.SessionCreateResult create(android.app.Activity activity);
+    method public static androidx.xr.runtime.SessionCreateResult create(android.app.Activity activity, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
+    method public void destroy();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public kotlinx.coroutines.CoroutineScope getCoroutineScope();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public androidx.xr.runtime.internal.Runtime getRuntime();
+    method public kotlinx.coroutines.flow.StateFlow<androidx.xr.runtime.CoreState> getState();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public java.util.List<androidx.xr.runtime.StateExtender> getStateExtenders();
+    method public void pause();
+    method public androidx.xr.runtime.SessionResumeResult resume();
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final kotlinx.coroutines.CoroutineScope coroutineScope;
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final androidx.xr.runtime.internal.Runtime runtime;
+    property public final kotlinx.coroutines.flow.StateFlow<androidx.xr.runtime.CoreState> state;
+    property @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final java.util.List<androidx.xr.runtime.StateExtender> stateExtenders;
+    field public static final androidx.xr.runtime.Session.Companion Companion;
+  }
+
+  public static final class Session.Companion {
+    method public androidx.xr.runtime.SessionCreateResult create(android.app.Activity activity);
+    method public androidx.xr.runtime.SessionCreateResult create(android.app.Activity activity, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionConfigureConfigurationNotSupported extends androidx.xr.runtime.SessionConfigureResult {
+    ctor public SessionConfigureConfigurationNotSupported();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class SessionConfigureResult {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionConfigureSuccess extends androidx.xr.runtime.SessionConfigureResult {
+    ctor public SessionConfigureSuccess();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionCreatePermissionsNotGranted extends androidx.xr.runtime.SessionCreateResult {
+    ctor public SessionCreatePermissionsNotGranted(java.util.List<java.lang.String> permissions);
+    method public java.util.List<java.lang.String> getPermissions();
+    property public final java.util.List<java.lang.String> permissions;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class SessionCreateResult {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionCreateSuccess extends androidx.xr.runtime.SessionCreateResult {
+    ctor public SessionCreateSuccess(androidx.xr.runtime.Session session);
+    method public androidx.xr.runtime.Session getSession();
+    property public final androidx.xr.runtime.Session session;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionResumePermissionsNotGranted extends androidx.xr.runtime.SessionResumeResult {
+    ctor public SessionResumePermissionsNotGranted(java.util.List<java.lang.String> permissions);
+    method public java.util.List<java.lang.String> getPermissions();
+    property public final java.util.List<java.lang.String> permissions;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class SessionResumeResult {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SessionResumeSuccess extends androidx.xr.runtime.SessionResumeResult {
+    ctor public SessionResumeSuccess();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface StateExtender {
+    method public suspend Object? extend(androidx.xr.runtime.CoreState coreState, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public void initialize(androidx.xr.runtime.internal.Runtime runtime);
+  }
+
+}
+
+package androidx.xr.runtime.internal {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Anchor {
+    method public void detach();
+    method public androidx.xr.runtime.internal.Anchor.PersistenceState getPersistenceState();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    method public java.util.UUID? getUuid();
+    method public void persist();
+    property public abstract androidx.xr.runtime.internal.Anchor.PersistenceState persistenceState;
+    property public abstract androidx.xr.runtime.math.Pose pose;
+    property public abstract androidx.xr.runtime.internal.TrackingState trackingState;
+    property public abstract java.util.UUID? uuid;
+  }
+
+  public static final class Anchor.PersistenceState {
+    field public static final androidx.xr.runtime.internal.Anchor.PersistenceState.Companion Companion;
+    field public static final androidx.xr.runtime.internal.Anchor.PersistenceState NotPersisted;
+    field public static final androidx.xr.runtime.internal.Anchor.PersistenceState Pending;
+    field public static final androidx.xr.runtime.internal.Anchor.PersistenceState Persisted;
+  }
+
+  public static final class Anchor.PersistenceState.Companion {
+    property public final androidx.xr.runtime.internal.Anchor.PersistenceState NotPersisted;
+    property public final androidx.xr.runtime.internal.Anchor.PersistenceState Pending;
+    property public final androidx.xr.runtime.internal.Anchor.PersistenceState Persisted;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class HitResult {
+    ctor public HitResult(float distance, androidx.xr.runtime.math.Pose hitPose, androidx.xr.runtime.internal.Trackable trackable);
+    method public float getDistance();
+    method public androidx.xr.runtime.math.Pose getHitPose();
+    method public androidx.xr.runtime.internal.Trackable getTrackable();
+    property public final float distance;
+    property public final androidx.xr.runtime.math.Pose hitPose;
+    property public final androidx.xr.runtime.internal.Trackable trackable;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface LifecycleManager {
+    method public void configure();
+    method public void create();
+    method public void pause();
+    method public void resume();
+    method public void stop();
+    method public suspend Object? update(kotlin.coroutines.Continuation<? super kotlin.time.ComparableTimeMark>);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface PerceptionManager {
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public java.util.List<java.util.UUID> getPersistedAnchorUuids();
+    method public java.util.Collection<androidx.xr.runtime.internal.Trackable> getTrackables();
+    method public java.util.List<androidx.xr.runtime.internal.HitResult> hitTest(androidx.xr.runtime.math.Ray ray);
+    method public androidx.xr.runtime.internal.Anchor loadAnchor(java.util.UUID uuid);
+    method public androidx.xr.runtime.internal.Anchor loadAnchorFromNativePointer(long nativePointer);
+    method public void unpersistAnchor(java.util.UUID uuid);
+    property public abstract java.util.Collection<androidx.xr.runtime.internal.Trackable> trackables;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Plane extends androidx.xr.runtime.internal.Trackable {
+    method public androidx.xr.runtime.math.Pose getCenterPose();
+    method public androidx.xr.runtime.math.Vector2 getExtents();
+    method public androidx.xr.runtime.internal.Plane.Label getLabel();
+    method public androidx.xr.runtime.internal.Plane? getSubsumedBy();
+    method public androidx.xr.runtime.internal.Plane.Type getType();
+    method public java.util.List<androidx.xr.runtime.math.Vector2> getVertices();
+    property public abstract androidx.xr.runtime.math.Pose centerPose;
+    property public abstract androidx.xr.runtime.math.Vector2 extents;
+    property public abstract androidx.xr.runtime.internal.Plane.Label label;
+    property public abstract androidx.xr.runtime.internal.Plane? subsumedBy;
+    property public abstract androidx.xr.runtime.internal.Plane.Type type;
+    property public abstract java.util.List<androidx.xr.runtime.math.Vector2> vertices;
+  }
+
+  public static final class Plane.Label {
+    field public static final androidx.xr.runtime.internal.Plane.Label Ceiling;
+    field public static final androidx.xr.runtime.internal.Plane.Label.Companion Companion;
+    field public static final androidx.xr.runtime.internal.Plane.Label Floor;
+    field public static final androidx.xr.runtime.internal.Plane.Label Table;
+    field public static final androidx.xr.runtime.internal.Plane.Label Unknown;
+    field public static final androidx.xr.runtime.internal.Plane.Label Wall;
+  }
+
+  public static final class Plane.Label.Companion {
+    property public final androidx.xr.runtime.internal.Plane.Label Ceiling;
+    property public final androidx.xr.runtime.internal.Plane.Label Floor;
+    property public final androidx.xr.runtime.internal.Plane.Label Table;
+    property public final androidx.xr.runtime.internal.Plane.Label Unknown;
+    property public final androidx.xr.runtime.internal.Plane.Label Wall;
+  }
+
+  public static final class Plane.Type {
+    field public static final androidx.xr.runtime.internal.Plane.Type.Companion Companion;
+    field public static final androidx.xr.runtime.internal.Plane.Type HorizontalDownwardFacing;
+    field public static final androidx.xr.runtime.internal.Plane.Type HorizontalUpwardFacing;
+    field public static final androidx.xr.runtime.internal.Plane.Type Vertical;
+  }
+
+  public static final class Plane.Type.Companion {
+    property public final androidx.xr.runtime.internal.Plane.Type HorizontalDownwardFacing;
+    property public final androidx.xr.runtime.internal.Plane.Type HorizontalUpwardFacing;
+    property public final androidx.xr.runtime.internal.Plane.Type Vertical;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Runtime {
+    method public androidx.xr.runtime.internal.LifecycleManager getLifecycleManager();
+    method public androidx.xr.runtime.internal.PerceptionManager getPerceptionManager();
+    property public abstract androidx.xr.runtime.internal.LifecycleManager lifecycleManager;
+    property public abstract androidx.xr.runtime.internal.PerceptionManager perceptionManager;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface RuntimeFactory {
+    method public androidx.xr.runtime.internal.Runtime createRuntime(android.app.Activity activity);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Space extends androidx.xr.runtime.internal.Trackable {
+  }
+
+  public static final class Space.Type {
+    field public static final androidx.xr.runtime.internal.Space.Type.Companion Companion;
+    field public static final androidx.xr.runtime.internal.Space.Type Local;
+    field public static final androidx.xr.runtime.internal.Space.Type LocalFloor;
+  }
+
+  public static final class Space.Type.Companion {
+    property public final androidx.xr.runtime.internal.Space.Type Local;
+    property public final androidx.xr.runtime.internal.Space.Type LocalFloor;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Trackable {
+    method public androidx.xr.runtime.internal.Anchor createAnchor(androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.runtime.internal.TrackingState getTrackingState();
+    property public abstract androidx.xr.runtime.internal.TrackingState trackingState;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class TrackingState {
+    field public static final androidx.xr.runtime.internal.TrackingState.Companion Companion;
+    field public static final androidx.xr.runtime.internal.TrackingState Paused;
+    field public static final androidx.xr.runtime.internal.TrackingState Stopped;
+    field public static final androidx.xr.runtime.internal.TrackingState Tracking;
+  }
+
+  public static final class TrackingState.Companion {
+    property public final androidx.xr.runtime.internal.TrackingState Paused;
+    property public final androidx.xr.runtime.internal.TrackingState Stopped;
+    property public final androidx.xr.runtime.internal.TrackingState Tracking;
+  }
+
+}
+
+package androidx.xr.runtime.java {
+
+  public final class Coroutines {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static <T> com.google.common.util.concurrent.ListenableFuture<T> toFuture(androidx.xr.runtime.Session session, kotlin.jvm.functions.Function2<? super kotlinx.coroutines.CoroutineScope,? super kotlin.coroutines.Continuation<? super T>,? extends java.lang.Object?> coroutine);
+  }
+
+  public final class Flows {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static <T> io.reactivex.rxjava3.core.Observable<T> toObservable(androidx.xr.runtime.Session session, kotlinx.coroutines.flow.Flow<? extends T> flow);
+  }
+
+}
+
+package androidx.xr.runtime.math {
+
+  public final class MathHelper {
+    method public static float clamp(float x, float min, float max);
+    method public static float lerp(float a, float b, float t);
+    method public static float toDegrees(float angleInRadians);
+    method public static float toRadians(float angleInDegrees);
+  }
+
+  public final class Matrix4 {
+    ctor public Matrix4(androidx.xr.runtime.math.Matrix4 other);
+    ctor public Matrix4(float[] dataToCopy);
+    method public androidx.xr.runtime.math.Matrix4 copy(optional float[] data);
+    method public static androidx.xr.runtime.math.Matrix4 fromPose(androidx.xr.runtime.math.Pose pose);
+    method public static androidx.xr.runtime.math.Matrix4 fromQuaternion(androidx.xr.runtime.math.Quaternion quaternion);
+    method public static androidx.xr.runtime.math.Matrix4 fromScale(androidx.xr.runtime.math.Vector3 scale);
+    method public static androidx.xr.runtime.math.Matrix4 fromScale(float scale);
+    method public static androidx.xr.runtime.math.Matrix4 fromTranslation(androidx.xr.runtime.math.Vector3 translation);
+    method public static androidx.xr.runtime.math.Matrix4 fromTrs(androidx.xr.runtime.math.Vector3 translation, androidx.xr.runtime.math.Quaternion rotation, androidx.xr.runtime.math.Vector3 scale);
+    method public float[] getData();
+    method public androidx.xr.runtime.math.Matrix4 getInverse();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.math.Quaternion getRotation();
+    method public androidx.xr.runtime.math.Vector3 getScale();
+    method public androidx.xr.runtime.math.Vector3 getTranslation();
+    method public androidx.xr.runtime.math.Matrix4 getTranspose();
+    method public boolean isTrs();
+    method public operator androidx.xr.runtime.math.Matrix4 times(androidx.xr.runtime.math.Matrix4 other);
+    property public final float[] data;
+    property public final androidx.xr.runtime.math.Matrix4 inverse;
+    property public final boolean isTrs;
+    property public final androidx.xr.runtime.math.Pose pose;
+    property public final androidx.xr.runtime.math.Quaternion rotation;
+    property public final androidx.xr.runtime.math.Vector3 scale;
+    property public final androidx.xr.runtime.math.Vector3 translation;
+    property public final androidx.xr.runtime.math.Matrix4 transpose;
+    field public static final androidx.xr.runtime.math.Matrix4.Companion Companion;
+    field public static final androidx.xr.runtime.math.Matrix4 Identity;
+    field public static final androidx.xr.runtime.math.Matrix4 Zero;
+  }
+
+  public static final class Matrix4.Companion {
+    method public androidx.xr.runtime.math.Matrix4 fromPose(androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.runtime.math.Matrix4 fromQuaternion(androidx.xr.runtime.math.Quaternion quaternion);
+    method public androidx.xr.runtime.math.Matrix4 fromScale(androidx.xr.runtime.math.Vector3 scale);
+    method public androidx.xr.runtime.math.Matrix4 fromScale(float scale);
+    method public androidx.xr.runtime.math.Matrix4 fromTranslation(androidx.xr.runtime.math.Vector3 translation);
+    method public androidx.xr.runtime.math.Matrix4 fromTrs(androidx.xr.runtime.math.Vector3 translation, androidx.xr.runtime.math.Quaternion rotation, androidx.xr.runtime.math.Vector3 scale);
+    property public final androidx.xr.runtime.math.Matrix4 Identity;
+    property public final androidx.xr.runtime.math.Matrix4 Zero;
+  }
+
+  public final class Pose {
+    ctor public Pose();
+    ctor public Pose(androidx.xr.runtime.math.Pose other);
+    ctor public Pose(optional androidx.xr.runtime.math.Vector3 translation);
+    ctor public Pose(optional androidx.xr.runtime.math.Vector3 translation, optional androidx.xr.runtime.math.Quaternion rotation);
+    method public infix androidx.xr.runtime.math.Pose compose(androidx.xr.runtime.math.Pose other);
+    method public androidx.xr.runtime.math.Pose copy();
+    method public androidx.xr.runtime.math.Pose copy(optional androidx.xr.runtime.math.Vector3 translation);
+    method public androidx.xr.runtime.math.Pose copy(optional androidx.xr.runtime.math.Vector3 translation, optional androidx.xr.runtime.math.Quaternion rotation);
+    method public static float distance(androidx.xr.runtime.math.Pose lhs, androidx.xr.runtime.math.Pose rhs);
+    method public static androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target);
+    method public static androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target, optional androidx.xr.runtime.math.Vector3 up);
+    method public inline androidx.xr.runtime.math.Vector3 getBackward();
+    method public inline androidx.xr.runtime.math.Vector3 getDown();
+    method public inline androidx.xr.runtime.math.Vector3 getForward();
+    method public androidx.xr.runtime.math.Pose getInverse();
+    method public inline androidx.xr.runtime.math.Vector3 getLeft();
+    method public inline androidx.xr.runtime.math.Vector3 getRight();
+    method public androidx.xr.runtime.math.Quaternion getRotation();
+    method public androidx.xr.runtime.math.Vector3 getTranslation();
+    method public inline androidx.xr.runtime.math.Vector3 getUp();
+    method public static androidx.xr.runtime.math.Pose lerp(androidx.xr.runtime.math.Pose start, androidx.xr.runtime.math.Pose end, float ratio);
+    method public androidx.xr.runtime.math.Pose rotate(androidx.xr.runtime.math.Quaternion rotation);
+    method public infix androidx.xr.runtime.math.Vector3 transformPoint(androidx.xr.runtime.math.Vector3 point);
+    method public infix androidx.xr.runtime.math.Vector3 transformVector(androidx.xr.runtime.math.Vector3 vector);
+    method public androidx.xr.runtime.math.Pose translate(androidx.xr.runtime.math.Vector3 translation);
+    property public final inline androidx.xr.runtime.math.Vector3 backward;
+    property public final inline androidx.xr.runtime.math.Vector3 down;
+    property public final inline androidx.xr.runtime.math.Vector3 forward;
+    property public final androidx.xr.runtime.math.Pose inverse;
+    property public final inline androidx.xr.runtime.math.Vector3 left;
+    property public final inline androidx.xr.runtime.math.Vector3 right;
+    property public final androidx.xr.runtime.math.Quaternion rotation;
+    property public final androidx.xr.runtime.math.Vector3 translation;
+    property public final inline androidx.xr.runtime.math.Vector3 up;
+    field public static final androidx.xr.runtime.math.Pose.Companion Companion;
+    field public static final androidx.xr.runtime.math.Pose Identity;
+  }
+
+  public static final class Pose.Companion {
+    method public float distance(androidx.xr.runtime.math.Pose lhs, androidx.xr.runtime.math.Pose rhs);
+    method public androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target);
+    method public androidx.xr.runtime.math.Pose fromLookAt(androidx.xr.runtime.math.Vector3 eye, androidx.xr.runtime.math.Vector3 target, optional androidx.xr.runtime.math.Vector3 up);
+    method public androidx.xr.runtime.math.Pose lerp(androidx.xr.runtime.math.Pose start, androidx.xr.runtime.math.Pose end, float ratio);
+    property public final androidx.xr.runtime.math.Pose Identity;
+  }
+
+  public final class Quaternion {
+    ctor public Quaternion();
+    ctor public Quaternion(androidx.xr.runtime.math.Quaternion other);
+    ctor public Quaternion(optional float x);
+    ctor public Quaternion(optional float x, optional float y);
+    ctor public Quaternion(optional float x, optional float y, optional float z);
+    ctor public Quaternion(optional float x, optional float y, optional float z, optional float w);
+    method public static float angle(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public androidx.xr.runtime.math.Quaternion copy();
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y, optional float z);
+    method public androidx.xr.runtime.math.Quaternion copy(optional float x, optional float y, optional float z, optional float w);
+    method public operator androidx.xr.runtime.math.Quaternion div(float c);
+    method public inline infix float dot(androidx.xr.runtime.math.Quaternion other);
+    method public static float dot(androidx.xr.runtime.math.Quaternion lhs, androidx.xr.runtime.math.Quaternion rhs);
+    method public static androidx.xr.runtime.math.Quaternion fromAxisAngle(androidx.xr.runtime.math.Vector3 axis, float degrees);
+    method public static androidx.xr.runtime.math.Quaternion fromEulerAngles(androidx.xr.runtime.math.Vector3 eulerAngles);
+    method public static androidx.xr.runtime.math.Quaternion fromEulerAngles(float pitch, float yaw, float roll);
+    method public static androidx.xr.runtime.math.Quaternion fromLookTowards(androidx.xr.runtime.math.Vector3 forward, androidx.xr.runtime.math.Vector3 up);
+    method public static androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public static androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end);
+    method public kotlin.Pair<androidx.xr.runtime.math.Vector3,java.lang.Float> getAxisAngle();
+    method public androidx.xr.runtime.math.Vector3 getEulerAngles();
+    method public inline androidx.xr.runtime.math.Quaternion getInverse();
+    method public float getW();
+    method public float getX();
+    method public float getY();
+    method public float getZ();
+    method public static androidx.xr.runtime.math.Quaternion lerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Quaternion minus(androidx.xr.runtime.math.Quaternion other);
+    method public inline operator androidx.xr.runtime.math.Quaternion plus(androidx.xr.runtime.math.Quaternion other);
+    method public static androidx.xr.runtime.math.Quaternion slerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Quaternion times(androidx.xr.runtime.math.Quaternion other);
+    method public inline operator androidx.xr.runtime.math.Vector3 times(androidx.xr.runtime.math.Vector3 src);
+    method public operator androidx.xr.runtime.math.Quaternion times(float c);
+    method public androidx.xr.runtime.math.Quaternion toNormalized();
+    method public inline operator androidx.xr.runtime.math.Quaternion unaryMinus();
+    property public final kotlin.Pair<androidx.xr.runtime.math.Vector3,java.lang.Float> axisAngle;
+    property public final androidx.xr.runtime.math.Vector3 eulerAngles;
+    property public final inline androidx.xr.runtime.math.Quaternion inverse;
+    property public final float w;
+    property public final float x;
+    property public final float y;
+    property public final float z;
+    field public static final androidx.xr.runtime.math.Quaternion.Companion Companion;
+    field public static final androidx.xr.runtime.math.Quaternion Identity;
+  }
+
+  public static final class Quaternion.Companion {
+    method public float angle(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public float dot(androidx.xr.runtime.math.Quaternion lhs, androidx.xr.runtime.math.Quaternion rhs);
+    method public androidx.xr.runtime.math.Quaternion fromAxisAngle(androidx.xr.runtime.math.Vector3 axis, float degrees);
+    method public androidx.xr.runtime.math.Quaternion fromEulerAngles(androidx.xr.runtime.math.Vector3 eulerAngles);
+    method public androidx.xr.runtime.math.Quaternion fromEulerAngles(float pitch, float yaw, float roll);
+    method public androidx.xr.runtime.math.Quaternion fromLookTowards(androidx.xr.runtime.math.Vector3 forward, androidx.xr.runtime.math.Vector3 up);
+    method public androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end);
+    method public androidx.xr.runtime.math.Quaternion fromRotation(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end);
+    method public androidx.xr.runtime.math.Quaternion lerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    method public androidx.xr.runtime.math.Quaternion slerp(androidx.xr.runtime.math.Quaternion start, androidx.xr.runtime.math.Quaternion end, float ratio);
+    property public final androidx.xr.runtime.math.Quaternion Identity;
+  }
+
+  public final class Ray {
+    ctor public Ray();
+    ctor public Ray(androidx.xr.runtime.math.Ray other);
+    ctor public Ray(optional androidx.xr.runtime.math.Vector3 origin, optional androidx.xr.runtime.math.Vector3 direction);
+    method public androidx.xr.runtime.math.Vector3 getDirection();
+    method public androidx.xr.runtime.math.Vector3 getOrigin();
+    property public final androidx.xr.runtime.math.Vector3 direction;
+    property public final androidx.xr.runtime.math.Vector3 origin;
+  }
+
+  public final class Vector2 {
+    ctor public Vector2();
+    ctor public Vector2(androidx.xr.runtime.math.Vector2 other);
+    ctor public Vector2(optional float x);
+    ctor public Vector2(optional float x, optional float y);
+    method public static androidx.xr.runtime.math.Vector2 abs(androidx.xr.runtime.math.Vector2 vector);
+    method public static float angularDistance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public androidx.xr.runtime.math.Vector2 clamp(androidx.xr.runtime.math.Vector2 min, androidx.xr.runtime.math.Vector2 max);
+    method public inline androidx.xr.runtime.math.Vector2 copy();
+    method public inline androidx.xr.runtime.math.Vector2 copy(optional float x);
+    method public inline androidx.xr.runtime.math.Vector2 copy(optional float x, optional float y);
+    method public inline infix float cross(androidx.xr.runtime.math.Vector2 other);
+    method public static float distance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public inline operator androidx.xr.runtime.math.Vector2 div(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 div(float c);
+    method public inline infix float dot(androidx.xr.runtime.math.Vector2 other);
+    method public inline float getLength();
+    method public inline float getLengthSquared();
+    method public float getX();
+    method public float getY();
+    method public static androidx.xr.runtime.math.Vector2 lerp(androidx.xr.runtime.math.Vector2 start, androidx.xr.runtime.math.Vector2 end, float ratio);
+    method public inline operator androidx.xr.runtime.math.Vector2 minus(androidx.xr.runtime.math.Vector2 other);
+    method public operator androidx.xr.runtime.math.Vector2 plus(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 times(androidx.xr.runtime.math.Vector2 other);
+    method public inline operator androidx.xr.runtime.math.Vector2 times(float c);
+    method public androidx.xr.runtime.math.Vector2 toNormalized();
+    method public inline operator androidx.xr.runtime.math.Vector2 unaryMinus();
+    property public final inline float length;
+    property public final inline float lengthSquared;
+    property public final float x;
+    property public final float y;
+    field public static final androidx.xr.runtime.math.Vector2.Companion Companion;
+    field public static final androidx.xr.runtime.math.Vector2 Down;
+    field public static final androidx.xr.runtime.math.Vector2 Left;
+    field public static final androidx.xr.runtime.math.Vector2 One;
+    field public static final androidx.xr.runtime.math.Vector2 Right;
+    field public static final androidx.xr.runtime.math.Vector2 Up;
+    field public static final androidx.xr.runtime.math.Vector2 Zero;
+  }
+
+  public static final class Vector2.Companion {
+    method public androidx.xr.runtime.math.Vector2 abs(androidx.xr.runtime.math.Vector2 vector);
+    method public float angularDistance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public float distance(androidx.xr.runtime.math.Vector2 vector1, androidx.xr.runtime.math.Vector2 vector2);
+    method public androidx.xr.runtime.math.Vector2 lerp(androidx.xr.runtime.math.Vector2 start, androidx.xr.runtime.math.Vector2 end, float ratio);
+    property public final androidx.xr.runtime.math.Vector2 Down;
+    property public final androidx.xr.runtime.math.Vector2 Left;
+    property public final androidx.xr.runtime.math.Vector2 One;
+    property public final androidx.xr.runtime.math.Vector2 Right;
+    property public final androidx.xr.runtime.math.Vector2 Up;
+    property public final androidx.xr.runtime.math.Vector2 Zero;
+  }
+
+  public final class Vector3 {
+    ctor public Vector3();
+    ctor public Vector3(androidx.xr.runtime.math.Vector3 other);
+    ctor public Vector3(optional float x);
+    ctor public Vector3(optional float x, optional float y);
+    ctor public Vector3(optional float x, optional float y, optional float z);
+    method public static androidx.xr.runtime.math.Vector3 abs(androidx.xr.runtime.math.Vector3 vector);
+    method public static float angleBetween(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public androidx.xr.runtime.math.Vector3 clamp(androidx.xr.runtime.math.Vector3 min, androidx.xr.runtime.math.Vector3 max);
+    method public androidx.xr.runtime.math.Vector3 copy();
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x);
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x, optional float y);
+    method public androidx.xr.runtime.math.Vector3 copy(optional float x, optional float y, optional float z);
+    method public infix androidx.xr.runtime.math.Vector3 cross(androidx.xr.runtime.math.Vector3 other);
+    method public static float distance(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public operator androidx.xr.runtime.math.Vector3 div(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 div(float c);
+    method public infix float dot(androidx.xr.runtime.math.Vector3 other);
+    method public static androidx.xr.runtime.math.Vector3 fromValue(float value);
+    method public inline float getLength();
+    method public inline float getLengthSquared();
+    method public float getX();
+    method public float getY();
+    method public float getZ();
+    method public static androidx.xr.runtime.math.Vector3 lerp(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end, float ratio);
+    method public static androidx.xr.runtime.math.Vector3 max(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public static androidx.xr.runtime.math.Vector3 min(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public operator androidx.xr.runtime.math.Vector3 minus(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 plus(androidx.xr.runtime.math.Vector3 other);
+    method public static androidx.xr.runtime.math.Vector3 projectOnPlane(androidx.xr.runtime.math.Vector3 vector, androidx.xr.runtime.math.Vector3 planeNormal);
+    method public operator androidx.xr.runtime.math.Vector3 times(androidx.xr.runtime.math.Vector3 other);
+    method public operator androidx.xr.runtime.math.Vector3 times(float c);
+    method public androidx.xr.runtime.math.Vector3 toNormalized();
+    method public operator androidx.xr.runtime.math.Vector3 unaryMinus();
+    property public final inline float length;
+    property public final inline float lengthSquared;
+    property public final float x;
+    property public final float y;
+    property public final float z;
+    field public static final androidx.xr.runtime.math.Vector3 Backward;
+    field public static final androidx.xr.runtime.math.Vector3.Companion Companion;
+    field public static final androidx.xr.runtime.math.Vector3 Down;
+    field public static final androidx.xr.runtime.math.Vector3 Forward;
+    field public static final androidx.xr.runtime.math.Vector3 Left;
+    field public static final androidx.xr.runtime.math.Vector3 One;
+    field public static final androidx.xr.runtime.math.Vector3 Right;
+    field public static final androidx.xr.runtime.math.Vector3 Up;
+    field public static final androidx.xr.runtime.math.Vector3 Zero;
+  }
+
+  public static final class Vector3.Companion {
+    method public androidx.xr.runtime.math.Vector3 abs(androidx.xr.runtime.math.Vector3 vector);
+    method public float angleBetween(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public float distance(androidx.xr.runtime.math.Vector3 vector1, androidx.xr.runtime.math.Vector3 vector2);
+    method public androidx.xr.runtime.math.Vector3 fromValue(float value);
+    method public androidx.xr.runtime.math.Vector3 lerp(androidx.xr.runtime.math.Vector3 start, androidx.xr.runtime.math.Vector3 end, float ratio);
+    method public androidx.xr.runtime.math.Vector3 max(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public androidx.xr.runtime.math.Vector3 min(androidx.xr.runtime.math.Vector3 a, androidx.xr.runtime.math.Vector3 b);
+    method public androidx.xr.runtime.math.Vector3 projectOnPlane(androidx.xr.runtime.math.Vector3 vector, androidx.xr.runtime.math.Vector3 planeNormal);
+    property public final androidx.xr.runtime.math.Vector3 Backward;
+    property public final androidx.xr.runtime.math.Vector3 Down;
+    property public final androidx.xr.runtime.math.Vector3 Forward;
+    property public final androidx.xr.runtime.math.Vector3 Left;
+    property public final androidx.xr.runtime.math.Vector3 One;
+    property public final androidx.xr.runtime.math.Vector3 Right;
+    property public final androidx.xr.runtime.math.Vector3 Up;
+    property public final androidx.xr.runtime.math.Vector3 Zero;
+  }
+
+}
+
diff --git a/xr/runtime/runtime/build.gradle b/xr/runtime/runtime/build.gradle
new file mode 100644
index 0000000..f670fa8
--- /dev/null
+++ b/xr/runtime/runtime/build.gradle
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+    id("kotlin-android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesCore)
+    api("androidx.annotation:annotation:1.8.1")
+
+    implementation(libs.guavaListenableFuture)
+    implementation(libs.kotlinCoroutinesRx3)
+    implementation(libs.kotlinCoroutinesGuava)
+    implementation("androidx.core:core:1.1.0")
+
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+    testImplementation(project(":kruth:kruth"))
+    testImplementation(project(":xr:runtime:runtime-testing"))
+    testImplementation("androidx.appcompat:appcompat:1.2.0")
+    testImplementation(libs.testRules)
+}
+
+android {
+    namespace "androidx.xr.runtime"
+    testOptions.unitTests.includeAndroidResources = true
+    defaultConfig {
+        consumerProguardFiles 'proguard-rules.pro'
+    }
+}
+
+androidx {
+    name = "XR Runtime"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Runtime interfaces and helper libraries for the androidx.xr namespace."
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/runtime/runtime/proguard-rules.pro b/xr/runtime/runtime/proguard-rules.pro
new file mode 100644
index 0000000..af2c756
--- /dev/null
+++ b/xr/runtime/runtime/proguard-rules.pro
@@ -0,0 +1,9 @@
+# Prevent the Internal and Math classes from being obfuscated as they are created from native code.
+-keep class androidx.xr.runtime.internal.** { *; }
+-keep class androidx.xr.runtime.internal.**$* { *; }
+-keep class androidx.xr.runtime.math.** { *; }
+-keep class androidx.xr.runtime.math.**$* { *; }
+-keep class * extends androidx.xr.runtime.internal.** { *; }
+-keep class * extends androidx.xr.runtime.internal.**$* { *; }
+-keep class * extends androidx.xr.runtime.math.** { *; }
+-keep class * extends androidx.xr.runtime.math.**$* { *; }
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoreState.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoreState.kt
new file mode 100644
index 0000000..e34fb42
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoreState.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import androidx.annotation.RestrictTo
+import kotlin.time.ComparableTimeMark
+
+/**
+ * Represents the state of the XR system at a specific point in time.
+ *
+ * @property timeMark at which the state was computed.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class CoreState(val timeMark: ComparableTimeMark) {}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoroutineDispatchers.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoroutineDispatchers.kt
new file mode 100644
index 0000000..f5c419e
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/CoroutineDispatchers.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import java.util.concurrent.Executors
+import kotlinx.coroutines.asCoroutineDispatcher
+
+/** Provides the [CoroutineDispatcher] objects for all coroutines in Jetpack XR Runtime. */
+internal object CoroutineDispatchers {
+
+    /** A [CoroutineDispatcher] for lightweight tasks that are small and non-blocking. */
+    val Lightweight = Executors.newCachedThreadPool().asCoroutineDispatcher()
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt
new file mode 100644
index 0000000..5537e0a
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import androidx.annotation.RestrictTo
+import androidx.core.content.ContextCompat
+import androidx.xr.runtime.internal.Runtime
+import androidx.xr.runtime.internal.RuntimeFactory
+import java.util.ServiceLoader
+import kotlin.time.TimeSource
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * A session is the main entrypoint to features provided by ARCore for Jetpack XR. It manages the
+ * system's state and its lifecycle, and contains the state of objects tracked by ARCore for Jetpack
+ * XR.
+ *
+ * This class owns a significant amount of native heap memory. Apps using a `Session` consider its
+ * lifecycle to ensure that native resources are released when the session is no longer needed. If
+ * your activity is a single XR-enabled activity, it is recommended to call the `Session` object's
+ * lifecycle methods from the activity's lifecycle methods using a
+ * [lifecycle-aware component](https://developer.android.com/topic/libraries/architecture/lifecycle).
+ * See [create], [resume], [pause], and [destroy] for more details.
+ */
+@Suppress("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Session
+internal constructor(
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public val runtime: Runtime,
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public val stateExtenders: List<StateExtender>,
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public val coroutineScope: CoroutineScope,
+    private val activity: Activity,
+) {
+    public companion object {
+        /**
+         * Creates a new [Session].
+         *
+         * Creating a session requires the `android.permission.SCENE_UNDERSTANDING` permission to be
+         * granted.
+         *
+         * @param activity the [Activity] that owns the session.
+         * @param coroutineDispatcher the [CoroutineDispatcher] that will be used to handle the
+         *   session's coroutines.
+         * @return the result of the operation. Can be [SessionCreateSuccess], which contains the
+         *   newly created session, or [SessionCreatePermissionsNotGranted] if the required
+         *   permissions haven't been granted.
+         */
+        @JvmOverloads
+        @JvmStatic
+        public fun create(
+            activity: Activity,
+            coroutineDispatcher: CoroutineDispatcher = CoroutineDispatchers.Lightweight,
+        ): SessionCreateResult {
+            val missingPermissions = activity.selectMissing(SESSION_PERMISSIONS)
+            if (missingPermissions.isNotEmpty()) {
+                return SessionCreatePermissionsNotGranted(missingPermissions)
+            }
+
+            val runtimeFactory: RuntimeFactory =
+                ServiceLoader.load(RuntimeFactory::class.java).iterator().next()
+            val runtime = runtimeFactory.createRuntime(activity)
+            runtime.lifecycleManager.create()
+
+            val stateExtenders = ServiceLoader.load(StateExtender::class.java).toList()
+            for (stateExtender in stateExtenders) {
+                stateExtender.initialize(runtime)
+            }
+            val session =
+                Session(runtime, stateExtenders, CoroutineScope(coroutineDispatcher), activity)
+            return SessionCreateSuccess(session)
+        }
+
+        internal val SESSION_PERMISSIONS: List<String> =
+            listOf("android.permission.SCENE_UNDERSTANDING")
+    }
+
+    /** The state of the runtime. */
+    private enum class RuntimeState {
+        INITIALIZED,
+        RESUMED,
+        PAUSED,
+        STOPPED,
+    }
+
+    private val _state = MutableStateFlow<CoreState>(CoreState(TimeSource.Monotonic.markNow()))
+
+    /** A [StateFlow] of the current state. */
+    public val state: StateFlow<CoreState> = _state.asStateFlow()
+
+    private var runtimeState: RuntimeState = RuntimeState.INITIALIZED
+    private var updateJob: Job? = null
+
+    /**
+     * Sets or changes the configuration to use.
+     *
+     * @return the result of the operation. This will be a [SessionConfigureSuccess] if the
+     *   configuration was successful, or a [SessionConfigureConfigurationNotSupported] if the
+     *   [SessionConfiguration] is not supported.
+     * @throws IllegalStateException if the session has been destroyed.
+     */
+    public fun configure(): SessionConfigureResult {
+        check(runtimeState != RuntimeState.STOPPED) { "Session has been destroyed." }
+        runtime.lifecycleManager.configure()
+        return SessionConfigureSuccess()
+    }
+
+    /**
+     * Starts or resumes the session.
+     *
+     * Resuming a session requires the `android.permission.SCENE_UNDERSTANDING` to be granted.
+     *
+     * @return the result of the operation. Can be [SessionResumeSuccess] if the session was
+     *   successfully resumed, or [SessionResumePermissionsNotGranted] if the required permissions
+     *   haven't been granted.
+     * @throws IllegalStateException if the session has been destroyed.
+     */
+    public fun resume(): SessionResumeResult {
+        check(runtimeState != RuntimeState.STOPPED) { "Session has been destroyed." }
+
+        if (runtimeState != RuntimeState.RESUMED) {
+            val missingPermissions = activity.selectMissing(SESSION_PERMISSIONS)
+            if (missingPermissions.isNotEmpty()) {
+                return SessionResumePermissionsNotGranted(missingPermissions)
+            }
+
+            runtime.lifecycleManager.resume()
+            runtimeState = RuntimeState.RESUMED
+            updateJob = coroutineScope.launch { updateLoop() }
+        }
+
+        return SessionResumeSuccess()
+    }
+
+    /**
+     * Pauses execution of the session. Objects tracked by the session will not receive updates. The
+     * system state will be retained in memory.
+     *
+     * Calling this method on an inactive session is a no-op.
+     *
+     * @throws IllegalStateException if the session has been destroyed.
+     */
+    public fun pause() {
+        check(runtimeState != RuntimeState.STOPPED) { "Session has been destroyed." }
+
+        if (runtimeState == RuntimeState.RESUMED) {
+            runtime.lifecycleManager.pause()
+            runtimeState = RuntimeState.PAUSED
+            updateJob?.cancel()
+            updateJob = null
+        }
+    }
+
+    /**
+     * Destroys the session, releasing any resources acquired by the session. Objects tracked by the
+     * system will not receive updates.
+     *
+     * Calling this method on a destroyed session is a no-op. Additionally, calling this method on
+     * an active session will first call [pause].
+     */
+    public fun destroy() {
+        if (runtimeState == RuntimeState.STOPPED) {
+            return
+        } else if (runtimeState == RuntimeState.RESUMED) {
+            pause()
+        }
+
+        runtime.lifecycleManager.stop()
+        coroutineScope.cancel()
+        runtimeState = RuntimeState.STOPPED
+    }
+
+    private suspend fun updateLoop() {
+        while (runtimeState == RuntimeState.RESUMED) {
+            update()
+        }
+    }
+
+    /** Produces the latest [CoreState] so it can be emitted downstream. */
+    private suspend fun update() {
+        val timeMark = runtime.lifecycleManager.update()
+        val state = CoreState(timeMark)
+
+        for (stateExtender in stateExtenders) {
+            stateExtender.extend(state)
+        }
+
+        _state.emit(state)
+    }
+}
+
+private fun Activity.selectMissing(permissions: List<String>): List<String> =
+    permissions.filter { permission -> !hasPermission(permission) }
+
+private fun Activity.hasPermission(permission: String): Boolean =
+    ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/SessionResults.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/SessionResults.kt
new file mode 100644
index 0000000..c01afc71
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/SessionResults.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import androidx.annotation.RestrictTo
+
+/** Result of a [Session.create] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public sealed class SessionCreateResult
+
+/**
+ * Result of a successful [Session.create] call.
+ *
+ * @property session the [Session] that was created.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionCreateSuccess(public val session: Session) : SessionCreateResult()
+
+/**
+ * Result of an unsuccessful [Session.create] call. The session was not created due to the required
+ * [permissions] not being granted.
+ *
+ * @property permissions the permissions that were not granted.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionCreatePermissionsNotGranted(public val permissions: List<String>) :
+    SessionCreateResult()
+
+/** Result of a [Session.configure] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public sealed class SessionConfigureResult
+
+/** Result of a successful [Session.configure] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionConfigureSuccess() : SessionConfigureResult()
+
+/**
+ * Result of an unsuccessful [Session.configure] call. The session was not configured due to the
+ * given [SessionConfig] not being supported.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionConfigureConfigurationNotSupported() : SessionConfigureResult()
+
+/** Result of a [Session.resume] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public sealed class SessionResumeResult
+
+/** Result of a successful [Session.resume] call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionResumeSuccess() : SessionResumeResult()
+
+/**
+ * Result of an unsuccessful [Session.resume] call. The session was not resumed due to the required
+ * [permissions] not being granted.
+ *
+ * @property permissions the permissions that were not granted.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SessionResumePermissionsNotGranted(public val permissions: List<String>) :
+    SessionResumeResult()
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/StateExtender.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/StateExtender.kt
new file mode 100644
index 0000000..f36fe20
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/StateExtender.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.internal.Runtime
+
+/** Class in charge of extending [CoreState] with a sub-state by using the [Runtime]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface StateExtender {
+    /** Initializes the [StateExtender]. */
+    public fun initialize(runtime: Runtime)
+
+    /** Extends [CoreState] with a package-specific sub-state. */
+    public suspend fun extend(coreState: CoreState)
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Anchor.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Anchor.kt
new file mode 100644
index 0000000..7528bd0
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Anchor.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+import java.util.UUID
+
+/** Describes a fixed location and orientation in the real world. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Anchor {
+
+    /** Describes the state of persistence for an [Anchor]. */
+    public class PersistenceState private constructor(private val value: Int) {
+        public companion object {
+            /** The anchor has not been requested to be persisted. */
+            @JvmField public val NotPersisted: PersistenceState = PersistenceState(0)
+
+            /** The anchor has been requested to be persisted but the operation is still pending. */
+            @JvmField public val Pending: PersistenceState = PersistenceState(1)
+
+            /** The anchor has been persisted. */
+            @JvmField public val Persisted: PersistenceState = PersistenceState(2)
+        }
+    }
+
+    /** The location of the anchor in the world coordinate space. */
+    public val pose: Pose
+
+    /** The current state of the pose of this anchor. */
+    public val trackingState: TrackingState
+
+    /** The [PersistenceState] for this anchor. */
+    public val persistenceState: PersistenceState
+
+    /** The [UUID] that identifies this Anchor if it is persisted. */
+    public val uuid: UUID?
+
+    /**
+     * Detaches this anchor from its [Trackable]. After detaching, the anchor will not be updated
+     * anymore and cannot be reattached to another trackable.
+     */
+    public fun detach()
+
+    /**
+     * Sends a request to persist this anchor. The value of [persistenceState] will be updated based
+     * on the progress of this operation. The value of [uuid] will be set if the operation is
+     * successful.
+     */
+    public fun persist()
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/HitResult.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/HitResult.kt
new file mode 100644
index 0000000..2c8d045
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/HitResult.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Defines an intersection between a ray and estimated real-world geometry.
+ *
+ * @property distance from the camera to the hit location, in meters.
+ * @property hitPose of the intersection between a ray and detected real-world geometry.
+ * @property trackable that was hit.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class HitResult(
+    public val distance: Float,
+    public val hitPose: Pose,
+    public val trackable: Trackable,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is HitResult) return false
+
+        if (distance != other.distance) return false
+        if (hitPose != other.hitPose) return false
+        if (trackable != other.trackable) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = distance.hashCode()
+        result = 31 * result + hitPose.hashCode()
+        result = 31 * result + trackable.hashCode()
+        return result
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/LifecycleManager.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/LifecycleManager.kt
new file mode 100644
index 0000000..5706f02
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/LifecycleManager.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import kotlin.time.ComparableTimeMark
+
+/** Describes the lifecycle a runtime implementation. */
+@Suppress("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface LifecycleManager {
+    /**
+     * Executes the [Runtime] initialization logic. It is necessary to call [resume] after calling
+     * this method to start the runtime's execution logic.
+     */
+    public fun create()
+
+    /**
+     * Sets or changes the configuration to use, which will affect the availability of properties or
+     * features in other managers. It is necessary to have called [create] before calling this
+     * method.
+     */
+    public fun configure()
+
+    /**
+     * Resumes execution from a paused or init state. It is necessary to have called [create] before
+     * calling this method.
+     */
+    public fun resume()
+
+    /**
+     * Updates the state of the system. The call is blocking and will return once the underlying
+     * implementation has been updated or a platform-specific timeout has been reached. This method
+     * can only be called when the runtime is resumed.
+     *
+     * @return the timemark of the latest state. This value is to be used for comparison with other
+     *   timemarks and not to be used for absolute time calculations.
+     */
+    public suspend fun update(): ComparableTimeMark
+
+    /** Pauses execution while retaining the state in memory. */
+    public fun pause()
+
+    /**
+     * Stops the execution and releases all resources. It is not valid to call any other method
+     * after calling [stop]. The runtime must not be resumed when this method is called.
+     */
+    public fun stop()
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/PerceptionManager.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/PerceptionManager.kt
new file mode 100644
index 0000000..a3a746d
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/PerceptionManager.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+import java.util.UUID
+import kotlin.collections.Collection
+import kotlin.collections.List
+
+/**
+ * Describes the perception functionality that is required from a [Runtime] implementation. It is
+ * expected that these functions are only valid while the [Runtime] is in a resumed state.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface PerceptionManager {
+    /** Defines a tracked location in the physical world. */
+    public fun createAnchor(pose: Pose): Anchor
+
+    /** Performs a ray cast in the direction of the given [ray] in the latest camera view. */
+    public fun hitTest(ray: Ray): List<HitResult>
+
+    /** Retrieves all the [UUID] instances from [Anchor] objects that have been persisted. */
+    public fun getPersistedAnchorUuids(): List<UUID>
+
+    /** Loads an [Anchor] from local storage. */
+    public fun loadAnchor(uuid: UUID): Anchor
+
+    /** Loads an [Anchor] from a native pointer. */
+    // TODO(b/373711152) : Remove this method once the Jetpack XR Runtime API migration is done.
+    public fun loadAnchorFromNativePointer(nativePointer: Long): Anchor
+
+    /** Deletes a persisted [Anchor] from local storage. */
+    public fun unpersistAnchor(uuid: UUID)
+
+    /** Returns the list of all known trackables. */
+    public val trackables: Collection<Trackable>
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Plane.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Plane.kt
new file mode 100644
index 0000000..5ca34aa
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Plane.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector2
+
+/** Describes the current best knowledge of a real-world planar surface. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Plane : Trackable {
+    /** The [Type] of the plane */
+    public val type: Type
+
+    /* The [Label] of the plane */
+    public val label: Label
+
+    /** The center of the detected plane. */
+    public val centerPose: Pose
+
+    /** The dimensions of the detected plane. */
+    public val extents: Vector2
+
+    /** If this plane has been subsumed, returns the plane this plane was merged into. */
+    public val subsumedBy: Plane?
+
+    /**
+     * Returns the 2D vertices (three or more) of a convex polygon approximating the detected plane.
+     */
+    public val vertices: List<Vector2>
+
+    /** Simple summary of the normal vector of a plane, for filtering purposes. */
+    public class Type private constructor(private val name: Int) {
+        public companion object {
+            /** A horizontal plane facing upward (e.g. floor or tabletop). */
+            @JvmField public val HorizontalUpwardFacing: Type = Type(0)
+
+            /** A horizontal plane facing downward (e.g. a ceiling). */
+            @JvmField public val HorizontalDownwardFacing: Type = Type(1)
+
+            /** A vertical plane (e.g. a wall). */
+            @JvmField public val Vertical: Type = Type(2)
+        }
+    }
+
+    /** A semantic description of a [Plane]. */
+    public class Label private constructor(private val name: Int) {
+        public companion object {
+            /** A plane of unknown type. */
+            @JvmField public val Unknown: Label = Label(0)
+
+            /** A plane that represents a wall. */
+            @JvmField public val Wall: Label = Label(1)
+
+            /** A plane that represents a floor. */
+            @JvmField public val Floor: Label = Label(2)
+
+            /** A plane that represents a ceiling. */
+            @JvmField public val Ceiling: Label = Label(3)
+
+            /** A plane that represents a table. */
+            @JvmField public val Table: Label = Label(4)
+        }
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Runtime.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Runtime.kt
new file mode 100644
index 0000000..ee82ece
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Runtime.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+
+/** Set of behaviors that collectively define a runtime. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Runtime {
+    /** Mandatory lifecycle runtime behavior. */
+    public val lifecycleManager: LifecycleManager
+
+    /** Mandatory perception runtime behavior. */
+    public val perceptionManager: PerceptionManager
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/RuntimeFactory.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/RuntimeFactory.kt
new file mode 100644
index 0000000..9f8ff48
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/RuntimeFactory.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import android.app.Activity
+import androidx.annotation.RestrictTo
+
+/** Factory for creating instances of Runtime. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface RuntimeFactory {
+    /** Creates a [Runtime] instance */
+    public fun createRuntime(activity: Activity): Runtime
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Space.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Space.kt
new file mode 100644
index 0000000..a0af994
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Space.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Describes a well-known coordinate system that is available for [anchors][Anchor] to be attached
+ * to.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Space : Trackable {
+    /** Describes the origin and extents of the well-known coordinate system. */
+    public class Type private constructor(private val name: Int) {
+        public companion object {
+            /**
+             * A world-locked origin useful when an application needs to render seated-scale content
+             * that is not positioned relative to the physical floor.
+             */
+            @JvmField public val Local: Type = Type(0)
+
+            /**
+             * Similar to [LOCAL] but with a different height/Y coordinate. Matches [STAGE] but with
+             * potentially reduced bounds.
+             */
+            @JvmField public val LocalFloor: Type = Type(1)
+        }
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Trackable.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Trackable.kt
new file mode 100644
index 0000000..1a6c5f1
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/Trackable.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/** A trackable is something can be tracked in space and that an [Anchor] can be attached to. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Trackable {
+    /**
+     * Creates an [Anchor] that is attached to this trackable, using the given initial [pose] in the
+     * world coordinate space.
+     */
+    public fun createAnchor(pose: Pose): Anchor
+
+    /** The [TrackingState] of this trackable. */
+    public val trackingState: TrackingState
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/TrackingState.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/TrackingState.kt
new file mode 100644
index 0000000..760d8c3
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/internal/TrackingState.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.annotation.RestrictTo
+
+/** Describes the state of the tracking performed. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class TrackingState private constructor(private val value: Int) {
+    public companion object {
+        /** The trackable is currently tracked and its pose is current. */
+        @JvmField public val Tracking: TrackingState = TrackingState(0)
+
+        /** Tracking has been paused for this instance but may be resumed in the future. */
+        @JvmField public val Paused: TrackingState = TrackingState(1)
+
+        /** Tracking has stopped for this instance and will never be resumed in the future. */
+        @JvmField public val Stopped: TrackingState = TrackingState(2)
+    }
+
+    public override fun toString(): String =
+        when (this) {
+            Tracking -> "Tracking"
+            Paused -> "Paused"
+            Stopped -> "Stopped"
+            else -> "Unknown"
+        }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Coroutines.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Coroutines.kt
new file mode 100644
index 0000000..a13981a
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Coroutines.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("Coroutines")
+
+package androidx.xr.runtime.java
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.Session
+import com.google.common.util.concurrent.ListenableFuture
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.guava.future
+
+/**
+ * Converts a coroutine created within the [session] to a [ListenableFuture].
+ *
+ * The returned [ListenableFuture] will be automatically cancelled when the [session] is destroyed.
+ *
+ * @param session the [Session] that originated the [coroutine].
+ * @param coroutine the coroutine to convert to a [ListenableFuture].
+ */
+@Suppress("AsyncSuffixFuture")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun <T> toFuture(
+    session: Session,
+    coroutine: suspend CoroutineScope.() -> T,
+): ListenableFuture<T> =
+    session.coroutineScope.future(
+        session.coroutineScope.coroutineContext,
+        CoroutineStart.DEFAULT,
+        coroutine,
+    )
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Flows.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Flows.kt
new file mode 100644
index 0000000..d8990c4
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/java/Flows.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("Flows")
+
+package androidx.xr.runtime.java
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.Session
+import io.reactivex.rxjava3.core.Observable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.rx3.asObservable
+
+/**
+ * Converts a [flow] created within the [session] to an [Observable].
+ *
+ * The returned [Observable] will be given the session's [CoroutineContext].
+ *
+ * @param session the [Session] that originated the [flow].
+ * @param flow the [Flow] to convert to an [Observable].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun <T : Any> toObservable(session: Session, flow: Flow<T>): Observable<T> =
+    flow.asObservable(session.coroutineScope.coroutineContext)
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/FloatExt.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/FloatExt.kt
new file mode 100644
index 0000000..0a238bd
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/FloatExt.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("MathHelper")
+@file:Suppress("NOTHING_TO_INLINE")
+
+package androidx.xr.runtime.math
+
+import kotlin.math.PI
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sqrt
+
+private const val DEGREES_PER_RADIAN = 180.0f / PI.toFloat()
+private const val RADIANS_PER_DEGREE = PI.toFloat() / 180.0f
+
+/** Calculates the reciprocal square root 1/sqrt(x) with a maximum error up to threshold. */
+internal inline fun rsqrt(x: Float): Float {
+    return 1 / sqrt(x)
+}
+
+/**
+ * Clamps a value.
+ *
+ * @param x the value to clamp.
+ * @param min the minimum value.
+ * @param max the maximum value.
+ */
+public fun clamp(x: Float, min: Float, max: Float): Float {
+    val result = min(max, max(min, x))
+    return result
+}
+
+/**
+ * Linearly interpolates between two values.
+ *
+ * @param a the start value.
+ * @param b the end value.
+ * @param t the ratio between the two floats.
+ * @return the interpolated value between [a] and [b].
+ */
+public fun lerp(a: Float, b: Float, t: Float): Float {
+    return a * (1.0f - t) + b * t
+}
+
+/** Converts [angleInRadians] from radians to degrees. */
+public fun toDegrees(angleInRadians: Float): Float = angleInRadians * DEGREES_PER_RADIAN
+
+/** Converts [angleInDegrees] from degrees to radians. */
+public fun toRadians(angleInDegrees: Float): Float = angleInDegrees * RADIANS_PER_DEGREE
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Matrix4.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Matrix4.kt
new file mode 100644
index 0000000..635bd04
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Matrix4.kt
@@ -0,0 +1,384 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import kotlin.math.sign
+import kotlin.math.sqrt
+
+/**
+ * An immutable 4x4 matrix that represents translation, scale, and rotation. The matrix is column
+ * major and right handed. The indexes of [dataToCopy] represent the following matrix layout:
+ * ```
+ *  [0, 4,  8, 12]
+ *  [1, 5,  9, 13]
+ *  [2, 6, 10, 14]
+ *  [3, 7, 11, 15]
+ * ```
+ *
+ * @param dataToCopy the array with 16 elements that will be copied over.
+ */
+public class Matrix4(dataToCopy: FloatArray) {
+    init {
+        // TODO: Consider using contracts to avoid the exception being inlined.
+        require(dataToCopy.size == 16) {
+            "Input array must have exactly 16 elements for a 4x4 matrix"
+        }
+    }
+
+    /** Returns an array of the components of this matrix. */
+    public val data: FloatArray = dataToCopy.copyOf()
+
+    /** Returns a matrix that performs the opposite transformation. */
+    public val inverse: Matrix4 by lazy(LazyThreadSafetyMode.NONE) { inverse() }
+
+    /** Returns a matrix that is the transpose of this matrix. */
+    public val transpose: Matrix4 by lazy(LazyThreadSafetyMode.NONE) { transpose() }
+
+    /** Returns the translation component of this matrix. */
+    public val translation: Vector3 by
+        lazy(LazyThreadSafetyMode.NONE) { Vector3(data[12], data[13], data[14]) }
+
+    /** Returns the scale component of this matrix. */
+    public val scale: Vector3 by lazy(LazyThreadSafetyMode.NONE) { scale() }
+
+    /** Returns the rotation component of this matrix. */
+    public val rotation: Quaternion by lazy(LazyThreadSafetyMode.NONE) { rotation() }
+
+    /** Returns the pose (i.e. rotation and translation) of this matrix. */
+    public val pose: Pose by lazy(LazyThreadSafetyMode.NONE) { Pose(translation, rotation) }
+
+    /**
+     * Returns true if this matrix is a valid transformation matrix that can be decomposed into
+     * translation, rotation and scale using determinant properties.
+     */
+    public val isTrs: Boolean by lazy(LazyThreadSafetyMode.NONE) { determinant() != 0.0f }
+
+    /** Creates a new matrix with a deep copy of the data from the [other] [Matrix4]. */
+    public constructor(other: Matrix4) : this(other.data.copyOf())
+
+    /**
+     * Returns a new matrix with the matrix multiplication product of this matrix and the [other]
+     * matrix.
+     */
+    public operator fun times(other: Matrix4): Matrix4 {
+        val result = Matrix4.Zero
+        android.opengl.Matrix.multiplyMM(
+            /* result= */ result.data,
+            /* resultOffset= */ 0,
+            /* lhs= */ this.data,
+            /* lhsOffset= */ 0,
+            /* rhs= */ other.data,
+            /* rhsOffset= */ 0,
+        )
+
+        return Matrix4(result.data)
+    }
+
+    private fun inverse(): Matrix4 {
+        val result = Matrix4.Zero
+        android.opengl.Matrix.invertM(
+            /* mInv= */ result.data,
+            /* mInvOffset= */ 0,
+            /* m= */ this.data,
+            /* mOffset= */ 0,
+        )
+
+        return Matrix4(result.data)
+    }
+
+    private fun transpose(): Matrix4 {
+        val result = Matrix4.Zero
+        android.opengl.Matrix.transposeM(
+            /* mTrans= */ result.data,
+            /* mTransOffset= */ 0,
+            /* m= */ this.data,
+            /* mOffset= */ 0,
+        )
+
+        return Matrix4(result.data)
+    }
+
+    private fun rotation(): Quaternion {
+        val m00 = data[0]
+        val m01 = data[4]
+        val m02 = data[8]
+        val m10 = data[1]
+        val m11 = data[5]
+        val m12 = data[9]
+        val m20 = data[2]
+        val m21 = data[6]
+        val m22 = data[10]
+
+        val trace = m00 + m11 + m22 + 1.0f
+
+        return if (trace > 0) {
+            val s = 0.5f / sqrt(trace)
+            Quaternion((m21 - m12) * s, (m02 - m20) * s, (m10 - m01) * s, 0.25f / s)
+        } else if ((m00 > m11) && (m00 > m22)) {
+            val s = 2.0f * sqrt(1.0f + m00 - m11 - m22)
+            Quaternion(0.25f * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s)
+        } else if (m11 > m22) {
+            val s = 2.0f * sqrt(1.0f + m11 - m00 - m22)
+            Quaternion((m01 + m10) / s, 0.25f * s, (m12 + m21) / s, (m02 - m20) / s)
+        } else {
+            val s = 2.0f * sqrt(1.0f + m22 - m00 - m11)
+            Quaternion((m02 + m20) / s, (m12 + m21) / s, 0.25f * s, (m10 - m01) / s)
+        }
+    }
+
+    private fun scale(): Vector3 {
+        // TODO: b/367780918 - Investigate why scale can have negative values when inputs were
+        // positive.
+        // We shouldn't use sign() directly because we don't want it to ever return 0
+        val signX = if (data[0] == 0.0f) 1.0f else sign(data[0])
+        val signY = if (data[5] == 0.0f) 1.0f else sign(data[5])
+        val signZ = if (data[10] == 0.0f) 1.0f else sign(data[10])
+        return Vector3(
+            signX * sqrt(data[0] * data[0] + data[1] * data[1] + data[2] * data[2]),
+            signY * sqrt(data[4] * data[4] + data[5] * data[5] + data[6] * data[6]),
+            signZ * sqrt(data[8] * data[8] + data[9] * data[9] + data[10] * data[10]),
+        )
+    }
+
+    /** Computes the determinant of a 4x4 matrix. */
+    private fun determinant(): Float =
+        data[0] *
+            (data[5] * (data[10] * data[15] - data[14] * data[11]) -
+                data[9] * (data[6] * data[15] - data[14] * data[7]) +
+                data[13] * (data[6] * data[11] - data[10] * data[7])) -
+            data[4] *
+                (data[1] * (data[10] * data[15] - data[14] * data[11]) -
+                    data[9] * (data[2] * data[15] - data[14] * data[3]) +
+                    data[13] * (data[2] * data[11] - data[10] * data[3])) +
+            data[8] *
+                (data[1] * (data[6] * data[15] - data[14] * data[7]) -
+                    data[5] * (data[2] * data[15] - data[14] * data[3]) +
+                    data[13] * (data[2] * data[7] - data[6] * data[3])) -
+            data[12] *
+                (data[1] * (data[6] * data[11] - data[10] * data[7]) -
+                    data[5] * (data[2] * data[11] - data[10] * data[3]) +
+                    data[9] * (data[2] * data[7] - data[6] * data[3]))
+
+    /** Returns true if this pose is equal to [other]. */
+    public override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Matrix4) return false
+
+        return this.data.contentEquals(other.data)
+    }
+
+    /** Standard hash code calculation using constructor values */
+    public override fun hashCode(): Int =
+        31 * data[0].hashCode() +
+            data[1].hashCode() +
+            data[2].hashCode() +
+            data[3].hashCode() +
+            data[4].hashCode() +
+            data[5].hashCode() +
+            data[6].hashCode() +
+            data[7].hashCode() +
+            data[8].hashCode() +
+            data[9].hashCode() +
+            data[10].hashCode() +
+            data[11].hashCode() +
+            data[12].hashCode() +
+            data[13].hashCode() +
+            data[14].hashCode() +
+            data[15].hashCode()
+
+    /** Standard toString() implementation */
+    public override fun toString(): String =
+        "\n[ " +
+            data[0] +
+            "\t" +
+            data[4] +
+            "\t" +
+            data[8] +
+            "\t" +
+            data[12] +
+            "\n  " +
+            data[1] +
+            "\t" +
+            data[5] +
+            "\t" +
+            data[9] +
+            "\t" +
+            data[13] +
+            "\n  " +
+            data[2] +
+            "\t" +
+            data[6] +
+            "\t" +
+            data[10] +
+            "\t" +
+            data[14] +
+            "\n  " +
+            data[3] +
+            "\t" +
+            data[7] +
+            "\t" +
+            data[11] +
+            "\t" +
+            data[15] +
+            " ]"
+
+    /** Returns a copy of the matrix. */
+    public fun copy(data: FloatArray = this.data): Matrix4 = Matrix4(data)
+
+    public companion object {
+        /** Returns an identity matrix. */
+        @JvmField
+        public val Identity: Matrix4 =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+
+        /** Returns a zero matrix. */
+        @JvmField
+        public val Zero: Matrix4 =
+            Matrix4(floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f))
+
+        /**
+         * Returns a new transformation matrix. The returned matrix is such that it first scales
+         * objects, then rotates them, and finally translates them.
+         */
+        @JvmStatic
+        public fun fromTrs(translation: Vector3, rotation: Quaternion, scale: Vector3): Matrix4 {
+            // implementationd details: https://www.songho.ca/opengl/gl_quaternion.html
+            val q = rotation.toNormalized()
+
+            // double var1 var2
+            val dqyx = 2 * q.y * q.x
+            val dqxz = 2 * q.x * q.z
+            val dqxw = 2 * q.x * q.w
+            val dqyw = 2 * q.y * q.w
+            val dqzw = 2 * q.z * q.w
+            val dqzy = 2 * q.z * q.y
+
+            // double var squared
+            val dsqz = 2 * q.z * q.z
+            val dsqy = 2 * q.y * q.y
+
+            val oneMinusDSQX = 1 - 2 * q.x * q.x
+
+            return Matrix4(
+                floatArrayOf(
+                    (1 - dsqy - dsqz) * scale.x,
+                    (dqyx + dqzw) * scale.x,
+                    (dqxz - dqyw) * scale.x,
+                    0.0f,
+                    (dqyx - dqzw) * scale.y,
+                    (oneMinusDSQX - dsqz) * scale.y,
+                    (dqzy + dqxw) * scale.y,
+                    0.0f,
+                    (dqxz + dqyw) * scale.z,
+                    (dqzy - dqxw) * scale.z,
+                    (oneMinusDSQX - dsqy) * scale.z,
+                    0.0f,
+                    translation.x,
+                    translation.y,
+                    translation.z,
+                    1.0f,
+                )
+            )
+        }
+
+        /** Returns a new translation matrix. */
+        @JvmStatic
+        public fun fromTranslation(translation: Vector3): Matrix4 =
+            Matrix4(
+                floatArrayOf(
+                    1.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    1.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    1.0f,
+                    0.0f,
+                    translation.x,
+                    translation.y,
+                    translation.z,
+                    1.0f,
+                )
+            )
+
+        /** Returns a new uniform scale matrix. */
+        @JvmStatic
+        public fun fromScale(scale: Vector3): Matrix4 =
+            Matrix4(
+                floatArrayOf(
+                    scale.x,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    scale.y,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    scale.z,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    1.0f,
+                )
+            )
+
+        /** Returns a new scale matrix. */
+        @JvmStatic
+        public fun fromScale(scale: Float): Matrix4 =
+            Matrix4(
+                floatArrayOf(
+                    scale,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    scale,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    scale,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    0.0f,
+                    1.0f,
+                )
+            )
+
+        /** Returns a new rotation matrix. */
+        @JvmStatic
+        public fun fromQuaternion(quaternion: Quaternion): Matrix4 =
+            fromTrs(Vector3.Zero, quaternion, Vector3.One)
+
+        /**
+         * Returns a new rigid transformation matrix. The returned matrix is such that it first
+         * rotates objects, and then translates them.
+         */
+        @JvmStatic
+        public fun fromPose(pose: Pose): Matrix4 {
+            return fromTrs(pose.translation, pose.rotation, Vector3.One)
+        }
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Pose.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Pose.kt
new file mode 100644
index 0000000..6bd40d7
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Pose.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+/**
+ * Represents an immutable rigid transformation from one coordinate space to another.
+ *
+ * @property translation the translation component of this pose.
+ * @property rotation the rotation component of this pose.
+ */
+public class Pose
+@JvmOverloads
+constructor(
+    public val translation: Vector3 = Vector3(),
+    public val rotation: Quaternion = Quaternion(),
+) {
+
+    /** Returns a pose that performs the opposite translation. */
+    public val inverse: Pose
+        get() = invert()
+
+    /** The up vector in the local coordinate system. */
+    public inline val up: Vector3
+        get() = rotation * Vector3.Up
+
+    /** The down vector in the local coordinate system. */
+    public inline val down: Vector3
+        get() = rotation * Vector3.Down
+
+    /** The left vector in the local coordinate system. */
+    public inline val left: Vector3
+        get() = rotation * Vector3.Left
+
+    /** The right vector in the local coordinate system. */
+    public inline val right: Vector3
+        get() = rotation * Vector3.Right
+
+    /** The forward vector in the local coordinate system. */
+    public inline val forward: Vector3
+        get() = rotation * Vector3.Forward
+
+    /** The backward vector in the local coordinate system. */
+    public inline val backward: Vector3
+        get() = rotation * Vector3.Backward
+
+    /** Creates a new pose with the same values as the [other] pose. */
+    public constructor(other: Pose) : this(other.translation, other.rotation)
+
+    /** Returns the result of composing [this] with [other]. */
+    public infix fun compose(other: Pose): Pose =
+        Pose(rotation * other.translation + this.translation, rotation * other.rotation)
+
+    /** Returns a pose that performs the opposite transformation. */
+    private fun invert(): Pose {
+        val outRotation = rotation.inverse
+        val outTranslation = -(outRotation * translation)
+
+        return Pose(outTranslation, outRotation)
+    }
+
+    /** Translates this pose by the given [translation]. */
+    public fun translate(translation: Vector3): Pose =
+        Pose(this.translation + translation, this.rotation)
+
+    /** Rotates this pose by the given [rotation]. */
+    public fun rotate(rotation: Quaternion): Pose = Pose(this.translation, this.rotation * rotation)
+
+    /**
+     * Transforms the provided point by the pose by applying both the rotation and the translation
+     * components of the pose. This is because a point represents a specific location in space. It
+     * needs to account for the position, scale and orientation of the space it is in.
+     */
+    public infix fun transformPoint(point: Vector3): Vector3 = rotation * point + translation
+
+    /**
+     * Transforms the provided vector by the pose by only applying the rotation component of the
+     * pose. This is because a vector represents a direction and magnitude, not a specific location.
+     * It only needs to account for the scale and orientation of the space it is in since it has no
+     * position.
+     */
+    public infix fun transformVector(vector: Vector3): Vector3 = rotation * vector
+
+    /** Returns a copy of the pose. */
+    @JvmOverloads
+    public fun copy(
+        translation: Vector3 = this.translation,
+        rotation: Quaternion = this.rotation,
+    ): Pose = Pose(translation, rotation)
+
+    /** Returns true if this pose is equal to the [other]. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Pose) return false
+
+        return this.translation == other.translation && this.rotation == other.rotation
+    }
+
+    override fun hashCode(): Int = 31 * translation.hashCode() + rotation.hashCode()
+
+    override fun toString(): String = "Pose{\n\tTranslation=$translation\n\tRotation=$rotation\n}"
+
+    public companion object {
+        /** Returns a new pose using the identity rotation. */
+        @JvmField public val Identity: Pose = Pose()
+
+        /**
+         * Returns a new pose oriented to look at [target] from [eye] position with [up] as the up
+         * vector.
+         *
+         * @param eye the position from which to look at [target].
+         * @param target the target position to look at.
+         * @param up a vector indicating the general "up" direction.
+         * @return the pose oriented to look at [target] from [eye] position with [up] as the up
+         *   vector.
+         */
+        @JvmStatic
+        @JvmOverloads
+        public fun fromLookAt(eye: Vector3, target: Vector3, up: Vector3 = Vector3.Up): Pose {
+            val forward = (target - eye).toNormalized()
+            val rotation = Quaternion.fromLookTowards(forward, up)
+
+            return Pose(eye, rotation)
+        }
+
+        /** Returns the distance between the two poses. */
+        @JvmStatic
+        public fun distance(lhs: Pose, rhs: Pose): Float =
+            Vector3.Companion.distance(lhs.translation, rhs.translation)
+
+        /**
+         * Returns a new pose that is linearly interpolated between [start] and [end] using the
+         * interpolation amount [ratio]. The position is [lerped][Vector3.lerp], but the rotation
+         * will be [slerped][Quaternion.slerp] if the angles are far apart.
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned pose will be extrapolated.
+         */
+        @JvmStatic
+        public fun lerp(start: Pose, end: Pose, ratio: Float): Pose {
+            val interpolatedPosition =
+                Vector3.Companion.lerp(start.translation, end.translation, ratio)
+
+            val interpolatedRotation =
+                if (start.rotation.dot(end.rotation) < 0.9995f) { // Check if angle is large
+                    Quaternion.Companion.slerp(start.rotation, end.rotation, ratio)
+                } else {
+                    // If the angle is small, lerp can be used for efficiency.
+                    // Note: This assumes both quaternions are normalized.
+                    Quaternion.Companion.lerp(start.rotation, end.rotation, ratio).toNormalized()
+                }
+
+            return Pose(interpolatedPosition, interpolatedRotation)
+        }
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Quaternion.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Quaternion.kt
new file mode 100644
index 0000000..9b19e12
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Quaternion.kt
@@ -0,0 +1,389 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package androidx.xr.runtime.math
+
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Represents a rotation component in three-dimensional space. Any vector can be provided and the
+ * resulting quaternion will be normalized at construction time.
+ *
+ * @param x the x value of the quaternion.
+ * @param y the y value of the quaternion.
+ * @param z the z value of the quaternion.
+ * @param w the rotation of the unit vector, in radians.
+ */
+public class Quaternion
+@JvmOverloads
+constructor(x: Float = 0F, y: Float = 0F, z: Float = 0F, w: Float = 1F) {
+    /** The normalized x component of the quaternion. */
+    public val x: Float
+    /** The normalized y component of the quaternion. */
+    public val y: Float
+    /** The normalized z component of the quaternion. */
+    public val z: Float
+    /** The normalized w component of the quaternion. */
+    public val w: Float
+
+    init {
+        val length = sqrt(x * x + y * y + z * z + w * w)
+        this.x = x / length
+        this.y = y / length
+        this.z = z / length
+        this.w = w / length
+    }
+
+    /** Returns a new quaternion with the inverse rotation. Assumes unit length. */
+    public inline val inverse: Quaternion
+        get() = Quaternion(-x, -y, -z, w)
+
+    /**
+     * Returns this quaternion as Euler angles (in degrees) applied in YXZ (yaw, pitch, roll) order.
+     */
+    public val eulerAngles: Vector3
+        get() = toYawPitchRoll()
+
+    /** Returns this quaternion as an axis/angle (in degrees) pair. */
+    public val axisAngle: Pair<Vector3, Float>
+        get() = toAxisAngle()
+
+    /** Creates a new quaternion with the same values as the [other] quaternion. */
+    public constructor(other: Quaternion) : this(other.x, other.y, other.z, other.w)
+
+    /** Creates a new quaternion using the components of a [Vector4]. */
+    internal constructor(vector: Vector4) : this(vector.x, vector.y, vector.z, vector.w)
+
+    /** Flips the sign of the quaternion, but represents the same rotation. */
+    public inline operator fun unaryMinus(): Quaternion = Quaternion(-x, -y, -z, -w)
+
+    /** Returns a new quaternion with the sum of this quaternion and [other] quaternion. */
+    public inline operator fun plus(other: Quaternion): Quaternion =
+        Quaternion(x + other.x, y + other.y, z + other.z, w + other.w)
+
+    /**
+     * Returns a new quaternion with the difference of this quaternion and the [other] quaternion.
+     */
+    public inline operator fun minus(other: Quaternion): Quaternion =
+        Quaternion(x - other.x, y - other.y, z - other.z, w - other.w)
+
+    /** Rotates a [Vector3] by this quaternion. */
+    public inline operator fun times(src: Vector3): Vector3 {
+        val qx = x
+        val qy = y
+        val qz = z
+        val qw = w
+        val vx = src.x
+        val vy = src.y
+        val vz = src.z
+
+        val rx = qy * vz - qz * vy + qw * vx
+        val ry = qz * vx - qx * vz + qw * vy
+        val rz = qx * vy - qy * vx + qw * vz
+        val sx = qy * rz - qz * ry
+        val sy = qz * rx - qx * rz
+        val sz = qx * ry - qy * rx
+        return Vector3(2 * sx + vx, 2 * sy + vy, 2 * sz + vz)
+    }
+
+    /**
+     * Returns a new quaternion with the product of this quaternion and the [other] quaternion. The
+     * order of the multiplication is `[this] * [other]`.
+     */
+    public inline operator fun times(other: Quaternion): Quaternion {
+        val lx = this.x
+        val ly = this.y
+        val lz = this.z
+        val lw = this.w
+        val rx = other.x
+        val ry = other.y
+        val rz = other.z
+        val rw = other.w
+
+        return Quaternion(
+            lw * rx + lx * rw + ly * rz - lz * ry,
+            lw * ry - lx * rz + ly * rw + lz * rx,
+            lw * rz + lx * ry - ly * rx + lz * rw,
+            lw * rw - lx * rx - ly * ry - lz * rz,
+        )
+    }
+
+    /** Returns a new quaternion with the product of this quaternion and a scalar amount. */
+    public operator fun times(c: Float): Quaternion = Quaternion(x * c, y * c, z * c, w * c)
+
+    /** Returns a new quaternion with this quaternion divided by a scalar amount. */
+    public operator fun div(c: Float): Quaternion = Quaternion(x / c, y / c, z / c, w / c)
+
+    /** Returns a new quaternion with a normalized rotation. */
+    public fun toNormalized(): Quaternion {
+        val norm = rsqrt(x * x + y * y + z * z + w * w)
+        return this * norm
+    }
+
+    /** Returns the dot product of this quaternion and the [other] quaternion. */
+    public inline infix fun dot(other: Quaternion): Float =
+        x * other.x + y * other.y + z * other.z + w * other.w
+
+    /**
+     * Get a [Vector3] containing the pitch, yaw and roll in degrees, extracted in YXZ (yaw, pitch,
+     * roll) order.
+     */
+    private fun toYawPitchRoll(): Vector3 {
+        val test = w * x - y * z
+        if (test > EULER_THRESHOLD) {
+            // There is a singularity when the pitch is directly up, so calculate the
+            // angles another way.
+            return Vector3(90f, toDegrees(-2 * atan2(z, w)), 0f)
+        }
+        if (test < -EULER_THRESHOLD) {
+            // There is a singularity when the pitch is directly down, so calculate the
+            // angles another way.
+            return Vector3(-90f, toDegrees(2 * atan2(z, w)), 0f)
+        }
+
+        val pitch = asin(2 * test)
+        val yaw = atan2(2 * (w * y + x * z).toDouble(), 1.0 - 2 * (x * x + y * y)).toFloat()
+        val roll = atan2(2 * (w * z + x * y).toDouble(), 1.0 - 2 * (x * x + z * z)).toFloat()
+
+        return Vector3(toDegrees(pitch), toDegrees(yaw), toDegrees(roll))
+    }
+
+    /** Returns a Pair containing the axis of rotation and the angle of rotation in degrees. */
+    private fun toAxisAngle(): Pair<Vector3, Float> {
+        val normalized = this.toNormalized()
+        val angleRadians = 2 * acos(normalized.w)
+        val sinHalfAngle = sin(angleRadians / 2)
+        val axis =
+            if (sinHalfAngle < 0.0001f) {
+                Vector3.Right // Default axis when angle is 0
+            } else {
+                Vector3(
+                    normalized.x / sinHalfAngle,
+                    normalized.y / sinHalfAngle,
+                    normalized.z / sinHalfAngle,
+                )
+            }
+
+        return Pair(axis, toDegrees(angleRadians))
+    }
+
+    /** Returns a copy of the quaternion. */
+    @JvmOverloads
+    public fun copy(
+        x: Float = this.x,
+        y: Float = this.y,
+        z: Float = this.z,
+        w: Float = this.w,
+    ): Quaternion = Quaternion(x, y, z, w)
+
+    /** Returns true if this quaternion is equal to the [other]. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Quaternion) return false
+
+        return this.x == other.x && this.y == other.y && this.z == other.z && this.w == other.w
+    }
+
+    override fun hashCode(): Int = 31 * x.hashCode() + y.hashCode() + z.hashCode() + w.hashCode()
+
+    override fun toString(): String = "[x=$x, y=$y, z=$z, w=$w]"
+
+    /** Returns a new quaternion with the identity rotation. */
+    public companion object {
+        private const val EULER_THRESHOLD: Float = 0.49999994f
+        private const val COS_THRESHOLD: Float = 0.9995f
+
+        @JvmField public val Identity: Quaternion = Quaternion()
+
+        /** Returns a new quaternion representing the rotation from one vector to another. */
+        @JvmStatic
+        public fun fromRotation(start: Vector3, end: Vector3): Quaternion {
+            val startNorm = start.toNormalized()
+            val endNorm = end.toNormalized()
+
+            val cosTheta = startNorm.dot(endNorm)
+            if (cosTheta < -COS_THRESHOLD) {
+                // Special case when vectors in opposite directions: no "ideal" rotation axis
+                // Guess one; any work as long as perpendicular to start
+                var rotationAxis = Vector3.Backward.cross(startNorm)
+                if (rotationAxis.lengthSquared < 0.01f) {
+                    rotationAxis =
+                        Vector3.Right.cross(
+                            startNorm
+                        ) // pick new rotation axis as the original was parallel
+                }
+                return Quaternion.Companion.fromAxisAngle(rotationAxis, 180f)
+            }
+
+            val rotationAxis = startNorm.cross(endNorm)
+
+            return Quaternion(rotationAxis.x, rotationAxis.y, rotationAxis.z, 1 + cosTheta)
+                .toNormalized()
+        }
+
+        /** Returns a new quaternion representing the rotation from one quaternion to another. */
+        @JvmStatic
+        public fun fromRotation(start: Quaternion, end: Quaternion): Quaternion =
+            Quaternion(end * start.inverse).toNormalized()
+
+        /** Returns a new quaternion with the specified forward and upward directions. */
+        @JvmStatic
+        public fun fromLookTowards(forward: Vector3, up: Vector3): Quaternion {
+            val forwardNormalized = forward.toNormalized()
+            val right = (up cross forwardNormalized).toNormalized()
+            val upNormalized = (forwardNormalized cross right).toNormalized()
+
+            val m00 = right.x
+            val m01 = right.y
+            val m02 = right.z
+            val m10 = upNormalized.x
+            val m11 = upNormalized.y
+            val m12 = upNormalized.z
+            val m20 = forwardNormalized.x
+            val m21 = forwardNormalized.y
+            val m22 = forwardNormalized.z
+
+            val trace = m00 + m11 + m22
+            return if (trace > 0) {
+                val s = 0.5f / sqrt(trace + 1.0f)
+                Quaternion((m12 - m21) * s, (m20 - m02) * s, (m01 - m10) * s, 0.25f / s)
+            } else {
+                if (m00 > m11 && m00 > m22) {
+                    val s = sqrt(1.0f + m00 - m11 - m22) * 2.0f
+                    Quaternion(0.25f * s, (m01 + m10) / s, (m02 + m20) / s, (m12 - m21) / s)
+                } else if (m11 > m22) {
+                    val s = sqrt(1.0f + m11 - m00 - m22) * 2.0f
+                    Quaternion((m01 + m10) / s, 0.25f * s, (m12 + m21) / s, (m20 - m02) / s)
+                } else {
+                    val s = sqrt(1.0f + m22 - m00 - m11) * 2.0f
+                    Quaternion((m02 + m20) / s, (m12 + m21) / s, 0.25f * s, (m01 - m10) / s)
+                }
+            }
+        }
+
+        /** Creates a new quaternion using an axis/angle to define the rotation. */
+        @JvmStatic
+        public fun fromAxisAngle(axis: Vector3, degrees: Float): Quaternion =
+            Quaternion(
+                sin(0.5f * toRadians(degrees)) * axis.toNormalized().x,
+                sin(0.5f * toRadians(degrees)) * axis.toNormalized().y,
+                sin(0.5f * toRadians(degrees)) * axis.toNormalized().z,
+                cos(0.5f * toRadians(degrees)),
+            )
+
+        /**
+         * Returns a new quaternion using Euler angles (in degrees) to define the rotation in YXZ
+         * (yaw, pitch, roll) order.
+         */
+        @JvmStatic
+        public fun fromEulerAngles(eulerAngles: Vector3): Quaternion =
+            Quaternion(fromAxisAngle(Vector3.Up, eulerAngles.y)) *
+                Quaternion(fromAxisAngle(Vector3.Right, eulerAngles.x)) *
+                Quaternion(fromAxisAngle(Vector3.Backward, eulerAngles.z))
+
+        /**
+         * Returns a new quaternion using Euler angles (in degrees) to define the rotation in YXZ
+         * (yaw, pitch, roll) order.
+         */
+        @JvmStatic
+        public fun fromEulerAngles(pitch: Float, yaw: Float, roll: Float): Quaternion =
+            Quaternion(fromAxisAngle(Vector3.Up, yaw)) *
+                Quaternion(fromAxisAngle(Vector3.Right, pitch)) *
+                Quaternion(fromAxisAngle(Vector3.Backward, roll))
+
+        /**
+         * Returns a new quaternion that is linearly interpolated between [start] and [end] using
+         * the interpolation amount [ratio].
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned quaternion will be
+         * extrapolated.
+         */
+        @JvmStatic
+        public fun lerp(start: Quaternion, end: Quaternion, ratio: Float): Quaternion =
+            Quaternion(
+                lerp(start.x, end.x, ratio),
+                lerp(start.y, end.y, ratio),
+                lerp(start.z, end.z, ratio),
+                lerp(start.w, end.w, ratio),
+            )
+
+        /**
+         * Returns a new quaternion that is spherically interpolated between [start] and [end] using
+         * the interpolation amount [ratio]. If [ratio] is 0, this returns [start]. As [ratio]
+         * approaches 1, the result of this function may approach either `+end` or `-end` (whichever
+         * is closest to [start]).
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned quaternion will be
+         * extrapolated.
+         */
+        @JvmStatic
+        public fun slerp(start: Quaternion, end: Quaternion, ratio: Float): Quaternion {
+            val orientationStart = start
+            var orientationEnd = end
+
+            // cosTheta0 provides the angle between the rotations at t = 0
+            var cosTheta0 = orientationStart.dot(orientationEnd)
+
+            // Flip end rotation to get shortest path between the two rotations
+            if (cosTheta0 < 0.0f) {
+                orientationEnd = -orientationEnd
+                cosTheta0 = -cosTheta0
+            }
+
+            // Small rotations can use linear interpolation
+            if (cosTheta0 > COS_THRESHOLD) {
+                return lerp(orientationStart, orientationEnd, ratio)
+            }
+
+            val sinTheta0 = sqrt(1.0 - cosTheta0 * cosTheta0)
+            val theta0 = acos(cosTheta0)
+            val thetaT = theta0 * ratio
+            val sinThetaT = sin(thetaT)
+            val costThetaT = cos(thetaT)
+
+            val s1 = sinThetaT / sinTheta0
+            val s0 = costThetaT - cosTheta0 * s1
+
+            // Do component-wise multiplication since s0 and s1 could be near-zero which would cause
+            // precision issues when (quat * 0.0f) is normalized due to division by near-zero
+            // length.
+            return Quaternion(
+                orientationStart.x * s0.toFloat() + orientationEnd.x * s1.toFloat(),
+                orientationStart.y * s0.toFloat() + orientationEnd.y * s1.toFloat(),
+                orientationStart.z * s0.toFloat() + orientationEnd.z * s1.toFloat(),
+                orientationStart.w * s0.toFloat() + orientationEnd.w * s1.toFloat(),
+            )
+        }
+
+        /** Returns the angle between [start] and [end] quaternion in degrees. */
+        @JvmStatic
+        public fun angle(start: Quaternion, end: Quaternion): Float =
+            toDegrees(2.0f * acos(abs(clamp(dot(start, end), -1.0f, 1.0f))))
+
+        /** Returns the dot product of two quaternions. */
+        @JvmStatic
+        public fun dot(lhs: Quaternion, rhs: Quaternion): Float =
+            lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Ray.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Ray.kt
new file mode 100644
index 0000000..f4d9113
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Ray.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+/**
+ * Represents a ray in 3D space. A ray is defined by an origin point and a direction vector.
+ *
+ * @property origin the origin of the ray.
+ * @property direction the direction of the ray.
+ */
+public class Ray(
+    public val origin: Vector3 = Vector3(),
+    public val direction: Vector3 = Vector3(),
+) {
+    /** Creates a new Ray with the same values as the [other] ray. */
+    public constructor(other: Ray) : this(other.origin, other.direction)
+
+    /** Returns true if this ray is equal to the [other] ray. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Ray) return false
+
+        return this.origin == other.origin && this.direction == other.direction
+    }
+
+    override fun hashCode(): Int = 31 * origin.hashCode() + direction.hashCode()
+
+    override fun toString(): String = "[origin=$origin, direction=$direction]"
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector2.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector2.kt
new file mode 100644
index 0000000..9f35620
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector2.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package androidx.xr.runtime.math
+
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sqrt
+
+/**
+ * Represents a position in the 2D plane.
+ *
+ * @property x X component of the vector.
+ * @property y Y component of the vector.
+ */
+public class Vector2 @JvmOverloads constructor(public val x: Float = 0F, public val y: Float = 0F) {
+    /** The squared length of the vector. */
+    public inline val lengthSquared: Float
+        get() = x * x + y * y
+
+    /** The length of the vector. */
+    public inline val length: Float
+        get() = sqrt(lengthSquared)
+
+    /** Creates a new vector with the same values as the [other] vector. */
+    public constructor(other: Vector2) : this(other.x, other.y)
+
+    /** Negates the values of this vector. */
+    public inline operator fun unaryMinus(): Vector2 = Vector2(-x, -y)
+
+    /** Returns a new vector with the sum of this vector and the [other] vector. */
+    public operator fun plus(other: Vector2): Vector2 = Vector2(this.x + other.x, this.y + other.y)
+
+    /** Returns a new vector with the difference of this vector and the [other] vector. */
+    public inline operator fun minus(other: Vector2): Vector2 =
+        Vector2(this.x - other.x, this.y - other.y)
+
+    /** Returns a new vector multiplied by a scalar amount */
+    public inline operator fun times(c: Float): Vector2 = Vector2(x * c, y * c)
+
+    /** Returns a new vector with the product of this vector and the [other] vector. */
+    public inline operator fun times(other: Vector2): Vector2 =
+        Vector2(this.x * other.x, this.y * other.y)
+
+    /** Returns a new vector with this vector divided by a scalar amount. */
+    public inline operator fun div(c: Float): Vector2 = Vector2(x / c, y / c)
+
+    /** Returns a new vector with this vector divided by the [other] vector. */
+    public inline operator fun div(other: Vector2): Vector2 = Vector2(x / other.x, y / other.y)
+
+    /** Returns a normalized version of this vector. */
+    public fun toNormalized(): Vector2 {
+        val norm = rsqrt(lengthSquared)
+
+        return Vector2(x * norm, y * norm)
+    }
+
+    /** Returns the cross product of this vector and the [other] vector. */
+    public inline infix fun cross(other: Vector2): Float = this.x * other.y - this.y * other.x
+
+    /** Returns the dot product of this vector and the [other] vector. */
+    public inline infix fun dot(other: Vector2): Float = x * other.x + y * other.y
+
+    /** Returns a new vector with the values clamped between [min] and [max] vectors. */
+    public fun clamp(min: Vector2, max: Vector2): Vector2 {
+        var clampedX = max(x, min.x)
+        var clampedY = max(y, min.y)
+
+        clampedX = min(clampedX, max.x)
+        clampedY = min(clampedY, max.y)
+
+        return Vector2(clampedX, clampedY)
+    }
+
+    /** Returns a copy of the vector. */
+    @JvmOverloads
+    public inline fun copy(x: Float = this.x, y: Float = this.y): Vector2 = Vector2(x, y)
+
+    /** Returns true if this vector is equal to the [other]. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Vector2) return false
+
+        return this.x == other.x && this.y == other.y
+    }
+
+    override fun hashCode(): Int = 31 * x.hashCode() + y.hashCode()
+
+    override fun toString(): String = "[x=$x, y=$y]"
+
+    public companion object {
+        /** Vector with all components set to zero. */
+        @JvmField public val Zero: Vector2 = Vector2(x = 0f, y = 0f)
+
+        /** Vector with all components set to one. */
+        @JvmField public val One: Vector2 = Vector2(x = 1f, y = 1f)
+
+        /** Vector with y set to one and all other components set to zero. */
+        @JvmField public val Up: Vector2 = Vector2(x = 0f, y = 1f)
+
+        /** Vector with y set to negative one and all other components set to zero. */
+        @JvmField public val Down: Vector2 = Vector2(x = 0f, y = -1f)
+
+        /** Vector with x set to negative one and all other components set to zero. */
+        @JvmField public val Left: Vector2 = Vector2(x = -1f, y = 0f)
+
+        /** Vector with x set to one and all other components set to zero. */
+        @JvmField public val Right: Vector2 = Vector2(x = 1f, y = 0f)
+
+        /** Returns the distance between this vector and the [other] vector. */
+        @JvmStatic
+        public fun distance(vector1: Vector2, vector2: Vector2): Float = (vector1 - vector2).length
+
+        /** Returns the angle between this vector and the [other] vector. */
+        @JvmStatic
+        public fun angularDistance(vector1: Vector2, vector2: Vector2): Float {
+            val dot = vector1 dot vector2
+            val magnitude = vector1.length * vector2.length
+
+            if (magnitude < 1e-10f) {
+                return 0.0f
+            }
+
+            // Clamp due to floating point precision errors that could cause dot to be > mag.
+            // Would cause acos to return NaN.
+            val cos = clamp(dot / magnitude, -1.0f, 1.0f)
+            val angleRadians = acos(cos)
+
+            return toDegrees(angleRadians)
+        }
+
+        /**
+         * Returns a new vector that is linearly interpolated between [start] and [end] using the
+         * interpolation amount [ratio].
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned vector will be extrapolated.
+         */
+        @JvmStatic
+        public fun lerp(start: Vector2, end: Vector2, ratio: Float): Vector2 =
+            Vector2(lerp(start.x, end.x, ratio), lerp(start.y, end.y, ratio))
+
+        /** Returns the absolute values of each component of the vector. */
+        @JvmStatic public fun abs(vector: Vector2): Vector2 = Vector2(abs(vector.x), abs(vector.y))
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector3.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector3.kt
new file mode 100644
index 0000000..59dac4c
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector3.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sqrt
+
+/**
+ * Represents a three-dimensional position in space.
+ *
+ * The coordinate system is right-handed. The [x]-axis points to the right, the [y]-axis up and the
+ * [z]-axis back.
+ *
+ * @property x the value of the horizontal component.
+ * @property y the value of the vertical component.
+ * @property z the value of the forward component.
+ */
+public class Vector3
+@JvmOverloads
+constructor(public val x: Float = 0F, public val y: Float = 0F, public val z: Float = 0F) {
+    /** The squared length of the vector. */
+    public inline val lengthSquared: Float
+        get() = x * x + y * y + z * z
+
+    /** The length of the vector. */
+    public inline val length: Float
+        get() = sqrt(lengthSquared)
+
+    /** Creates a new vector with the same values as the [other] vector. */
+    public constructor(other: Vector3) : this(other.x, other.y, other.z)
+
+    /** Negates this vector. */
+    public operator fun unaryMinus(): Vector3 = Vector3(-x, -y, -z)
+
+    /** Returns a new vector with the sum of this vector and the [other] vector. */
+    public operator fun plus(other: Vector3): Vector3 =
+        Vector3(x + other.x, y + other.y, z + other.z)
+
+    /** Returns a new vector with the difference of this vector and the [other] vector. */
+    public operator fun minus(other: Vector3): Vector3 =
+        Vector3(x - other.x, y - other.y, z - other.z)
+
+    /** Get a new vector multiplied by a scalar amount. */
+    public operator fun times(c: Float): Vector3 = Vector3(x * c, y * c, z * c)
+
+    /** Returns a new vector with the product of this vector and the [other] vector. */
+    public operator fun times(other: Vector3): Vector3 =
+        Vector3(x * other.x, y * other.y, z * other.z)
+
+    /** Returns a new vector with this vector divided by a scalar amount. */
+    public operator fun div(c: Float): Vector3 = Vector3(x / c, y / c, z / c)
+
+    /** Returns a new vector with this vector divided by the [other] vector. */
+    public operator fun div(other: Vector3): Vector3 =
+        Vector3(x / other.x, y / other.y, z / other.z)
+
+    /** Returns the dot product of this vector and the [other] vector. */
+    public infix fun dot(other: Vector3): Float = x * other.x + y * other.y + z * other.z
+
+    /** Returns the cross product of this vector and the [other] vector. */
+    public infix fun cross(other: Vector3): Vector3 =
+        Vector3(y * other.z - z * other.y, z * other.x - x * other.z, x * other.y - y * other.x)
+
+    /** Returns the normalized version of this vector. */
+    public fun toNormalized(): Vector3 {
+        val norm = rsqrt(lengthSquared)
+
+        return Vector3(x * norm, y * norm, z * norm)
+    }
+
+    /** Returns a new vector with its values clamped between [min] and [max] vectors. */
+    public fun clamp(min: Vector3, max: Vector3): Vector3 {
+        var clampedX = clamp(x, min.x, max.x)
+        var clampedY = clamp(y, min.y, max.y)
+        var clampedZ = clamp(z, min.z, max.z)
+
+        return Vector3(clampedX, clampedY, clampedZ)
+    }
+
+    /** Returns a copy of the vector. */
+    @JvmOverloads
+    public fun copy(x: Float = this.x, y: Float = this.y, z: Float = this.z): Vector3 =
+        Vector3(x, y, z)
+
+    /** Returns true if this vector is equal to [other]. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Vector3) return false
+
+        return this.x == other.x && this.y == other.y && this.z == other.z
+    }
+
+    override fun hashCode(): Int = 31 * x.hashCode() + y.hashCode() + z.hashCode()
+
+    override fun toString(): String = "[x=$x, y=$y, z=$z]"
+
+    public companion object {
+        /** Vector with all components set to zero. */
+        @JvmField public val Zero: Vector3 = Vector3(x = 0f, y = 0f, z = 0f)
+
+        /** Vector with all components set to one. */
+        @JvmField public val One: Vector3 = Vector3(x = 1f, y = 1f, z = 1f)
+
+        /** Vector with y set to one and all other components set to zero. */
+        @JvmField public val Up: Vector3 = Vector3(x = 0f, y = 1f, z = 0f)
+
+        /** Vector with y set to negative one and all other components set to zero. */
+        @JvmField public val Down: Vector3 = Vector3(x = 0f, y = -1f, z = 0f)
+
+        /** Vector with x set to negative one and all other components set to zero. */
+        @JvmField public val Left: Vector3 = Vector3(x = -1f, y = 0f, z = 0f)
+
+        /** Vector with x set to one and all other components set to zero. */
+        @JvmField public val Right: Vector3 = Vector3(x = 1f, y = 0f, z = 0f)
+
+        /** Vector with z set to one and all other components set to zero. */
+        @JvmField public val Backward: Vector3 = Vector3(x = 0f, y = 0f, z = 1f)
+
+        /** Vector with z set to negative one and all other components set to zero. */
+        @JvmField public val Forward: Vector3 = Vector3(x = 0f, y = 0f, z = -1f)
+
+        /** Creates a new vector with all components set to [value]. */
+        @JvmStatic public fun fromValue(value: Float): Vector3 = Vector3(value, value, value)
+
+        /**
+         * Returns the angle between this vector and the [other] vector in degrees. The result is
+         * never greater than 180 degrees.
+         */
+        @JvmStatic
+        public fun angleBetween(vector1: Vector3, vector2: Vector3): Float {
+            val dot = vector1 dot vector2
+            val magnitude = vector1.length * vector2.length
+
+            if (magnitude < 1e-10f) {
+                return 0.0f
+            }
+
+            // Clamp due to floating point precision errors that could cause dot to be > mag.
+            // Would cause acos to return NaN.
+            val cos = clamp(dot / magnitude, -1.0f, 1.0f)
+
+            return acos(cos)
+        }
+
+        /** Returns the distance between this vector and the [other] vector. */
+        @JvmStatic
+        public fun distance(vector1: Vector3, vector2: Vector3): Float = (vector1 - vector2).length
+
+        /**
+         * Returns a new vector that is linearly interpolated between [start] and [end] using the
+         * interpolated amount [ratio].
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned vector will be extrapolated.
+         */
+        @JvmStatic
+        public fun lerp(start: Vector3, end: Vector3, ratio: Float): Vector3 =
+            Vector3(
+                lerp(start.x, end.x, ratio),
+                lerp(start.y, end.y, ratio),
+                lerp(start.z, end.z, ratio)
+            )
+
+        /** Returns the minimum of each component of the two vectors. */
+        @JvmStatic
+        public fun min(a: Vector3, b: Vector3): Vector3 =
+            Vector3(min(a.x, b.x), min(a.y, b.y), min(a.z, b.z))
+
+        /** Returns the maximum of each component of the two vectors. */
+        @JvmStatic
+        public fun max(a: Vector3, b: Vector3): Vector3 =
+            Vector3(max(a.x, b.x), max(a.y, b.y), max(a.z, b.z))
+
+        /** Computes the vector projected from [vector] onto [planeNormal]. */
+        @JvmStatic
+        public fun projectOnPlane(vector: Vector3, planeNormal: Vector3): Vector3 =
+            vector - planeNormal * (vector dot planeNormal) / (planeNormal dot planeNormal)
+
+        /** Returns the absolute values of each component of the vector. */
+        @JvmStatic
+        public fun abs(vector: Vector3): Vector3 =
+            Vector3(abs(vector.x), abs(vector.y), abs(vector.z))
+    }
+}
diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector4.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector4.kt
new file mode 100644
index 0000000..29d137c
--- /dev/null
+++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/math/Vector4.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sqrt
+
+/**
+ * Represents a four-dimensional position in space.
+ *
+ * @param x X component of the vector.
+ * @param y Y component of the vector.
+ * @param z Z component of the vector.
+ * @param w W component of the vector.
+ */
+internal class Vector4
+@JvmOverloads
+constructor(
+    public val x: Float = 0F,
+    public val y: Float = 0F,
+    public val z: Float = 0F,
+    public val w: Float = 0F,
+) {
+    /** The squared length of the vector. */
+    public inline val lengthSquared: Float
+        get() = x * x + y * y + z * z + w * w
+
+    /** The length of the vector. */
+    public inline val length: Float
+        get() = sqrt(lengthSquared)
+
+    /** Creates a new vector with the same values as the [other] vector. */
+    public constructor(other: Vector4) : this(other.x, other.y, other.z, other.w)
+
+    /** Negates this vector. */
+    public operator fun unaryMinus(): Vector4 = Vector4(-x, -y, -z, -w)
+
+    /** Returns a new vector with the sum of this vector and the [other] vector. */
+    public operator fun plus(other: Vector4): Vector4 =
+        Vector4(x + other.x, y + other.y, z + other.z, w + other.w)
+
+    /** Returns a new vector with the difference of this vector and the [other] vector. */
+    public operator fun minus(other: Vector4): Vector4 =
+        Vector4(x - other.x, y - other.y, z - other.z, w - other.w)
+
+    /** Get a new vector multiplied by a scalar amount. */
+    public operator fun times(c: Float): Vector4 = Vector4(x * c, y * c, z * c, w * c)
+
+    /** Returns a new vector with the product of this vector and the [other] vector. */
+    public operator fun times(other: Vector4): Vector4 =
+        Vector4(x * other.x, y * other.y, z * other.z, w * other.w)
+
+    /** Returns a new vector with this vector divided by a scalar amount. */
+    public operator fun div(c: Float): Vector4 = Vector4(x / c, y / c, z / c, w / c)
+
+    /** Returns a new vector with this vector divided by the [other] vector. */
+    public operator fun div(other: Vector4): Vector4 =
+        Vector4(x / other.x, y / other.y, z / other.z, w / other.w)
+
+    /** Returns the dot product of this vector and the [other] vector. */
+    public infix fun dot(other: Vector4): Float =
+        x * other.x + y * other.y + z * other.z + w * other.w
+
+    /** Returns the normalized version of this vector. */
+    public fun toNormalized(): Vector4 {
+        val norm = rsqrt(lengthSquared)
+
+        return Vector4(x * norm, y * norm, z * norm, w * norm)
+    }
+
+    /** Returns a new vector with the values clamped between [min] and [max] vectors. */
+    public fun clamp(min: Vector4, max: Vector4): Vector4 {
+        val clampedX = clamp(x, min.x, max.x)
+        val clampedY = clamp(y, min.y, max.y)
+        val clampedZ = clamp(z, min.z, max.z)
+        val clampedW = clamp(w, min.w, max.w)
+
+        return Vector4(clampedX, clampedY, clampedZ, clampedW)
+    }
+
+    /** Returns a copy of the vector. */
+    @JvmOverloads
+    public fun copy(
+        x: Float = this.x,
+        y: Float = this.y,
+        z: Float = this.z,
+        w: Float = this.w,
+    ): Vector4 = Vector4(x, y, z, w)
+
+    /** Returns true if this vector is equal to the [other]. */
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is Vector4) return false
+
+        return this.x == other.x && this.y == other.y && this.z == other.z && this.w == other.w
+    }
+
+    override fun hashCode(): Int = 31 * x.hashCode() + y.hashCode() + z.hashCode() + w.hashCode()
+
+    override fun toString(): String = "[x=$x, y=$y, z=$z, w=$w]"
+
+    public companion object {
+        /** Vector with all components set to zero. */
+        @JvmField public val Zero: Vector4 = Vector4(x = 0f, y = 0f, z = 0f, w = 0f)
+
+        /** Vector with all components set to one. */
+        @JvmField public val One: Vector4 = Vector4(x = 1f, y = 1f, z = 1f, w = 1f)
+
+        /** Creates a new vector with all components set to [value]. */
+        @JvmStatic public fun fromValue(value: Float): Vector4 = Vector4(value, value, value, value)
+
+        /**
+         * Returns the angle between this vector and [other] vector in degrees. The result is never
+         * greater than 180 degrees.
+         */
+        @JvmStatic
+        public fun angleBetween(vector1: Vector4, vector2: Vector4): Float {
+            val dot = vector1 dot vector2
+            val magnitude = vector1.length * vector2.length
+
+            if (magnitude < 1e-10f) {
+                return 0.0f
+            }
+
+            // Clamp due to floating point precision errors that could cause dot to be > mag.
+            // Would cause acos to return NaN.
+            val cos = clamp(dot / magnitude, -1.0f, 1.0f)
+
+            return acos(cos)
+        }
+
+        /** Returns the distance between this vector and the [other] vector. */
+        @JvmStatic
+        public fun distance(vector1: Vector4, vector2: Vector4): Float = (vector1 - vector2).length
+
+        /**
+         * Returns a new vector that is linearly interpolated between [start] and [end] using the
+         * interpolation amount [ratio].
+         *
+         * If [ratio] is outside of the range `[0, 1]`, the returned vector will be extrapolated.
+         */
+        @JvmStatic
+        public fun lerp(start: Vector4, end: Vector4, ratio: Float): Vector4 =
+            Vector4(
+                lerp(start.x, end.x, ratio),
+                lerp(start.y, end.y, ratio),
+                lerp(start.z, end.z, ratio),
+                lerp(start.w, end.w, ratio),
+            )
+
+        /** Returns the minimum of each component of the two vectors. */
+        @JvmStatic
+        public fun min(a: Vector4, b: Vector4): Vector4 =
+            Vector4(min(a.x, b.x), min(a.y, b.y), min(a.z, b.z), min(a.w, b.w))
+
+        /** Returns the maximum of each component of the two vectors. */
+        @JvmStatic
+        public fun max(a: Vector4, b: Vector4): Vector4 =
+            Vector4(max(a.x, b.x), max(a.y, b.y), max(a.z, b.z), max(a.w, b.w))
+
+        /** Returns the absolute values of each component of the vector. */
+        @JvmStatic
+        public fun abs(vector: Vector4): Vector4 =
+            Vector4(abs(vector.x), abs(vector.y), abs(vector.z), abs(vector.w))
+    }
+}
diff --git a/xr/runtime/runtime/src/test/AndroidManifest.xml b/xr/runtime/runtime/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..666fbed
--- /dev/null
+++ b/xr/runtime/runtime/src/test/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <application>
+        <activity android:name="android.app.Activity" />
+    </application>
+</manifest>
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/SessionTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/SessionTest.kt
new file mode 100644
index 0000000..3110bae
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/SessionTest.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.testing.FakeLifecycleManager
+import androidx.xr.runtime.testing.FakeStateExtender
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class SessionTest {
+    private lateinit var activity: Activity
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testScope: TestScope
+
+    @get:Rule val activityScenarioRule = ActivityScenarioRule<Activity>(Activity::class.java)
+
+    @Before
+    fun setUp() {
+        activityScenarioRule.scenario.onActivity { this.activity = it }
+        shadowOf(activity).grantPermissions(*Session.SESSION_PERMISSIONS.toTypedArray())
+
+        testDispatcher = StandardTestDispatcher()
+        testScope = TestScope(testDispatcher)
+    }
+
+    @Test
+    fun create_returnsSuccessResultWithNonNullSession() {
+        val result = Session.create(activity) as SessionCreateSuccess
+
+        assertThat(result.session).isNotNull()
+    }
+
+    @Test
+    fun create_setsLifecycleToInitialized() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+
+        val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+        assertThat(lifecycleManager.state).isEqualTo(FakeLifecycleManager.State.INITIALIZED)
+    }
+
+    @Test
+    fun create_initializesStateExtender() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+
+        // The FakeStateExtender is being loaded in Session here because it is defined as a class in
+        // the
+        // "//third_party/arcore/androidx/java/androidx/xr/testing" dependency.
+        val stateExtender = underTest.stateExtenders.first() as FakeStateExtender
+        assertThat(stateExtender.isInitialized).isTrue()
+    }
+
+    @Test
+    fun create_permissionNotGranted_returnsPermissionsNotGranted() {
+        val permission = "android.permission.SCENE_UNDERSTANDING"
+        shadowOf(activity).denyPermissions(permission)
+
+        val result = Session.create(activity) as SessionCreatePermissionsNotGranted
+
+        assertThat(result.permissions).containsExactly(permission)
+    }
+
+    @Test
+    fun configure_destroyed_throwsIllegalStateException() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        underTest.destroy()
+
+        assertFailsWith<IllegalStateException> { underTest.configure() }
+    }
+
+    // TODO(b/349855733): Add a test to verify configure() calls the corresponding LifecycleManager
+    // method once FakeRuntime supports it.
+
+    @Test
+    fun resume_returnsSuccessAndSetsLifecycleToResumed() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+
+        val result = underTest.resume()
+
+        assertThat(result).isInstanceOf(SessionResumeSuccess::class.java)
+        val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+        assertThat(lifecycleManager.state).isEqualTo(FakeLifecycleManager.State.RESUMED)
+    }
+
+    @Test
+    fun resume_destroyed_throwsIllegalStateException() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        underTest.destroy()
+
+        assertFailsWith<IllegalStateException> { underTest.resume() }
+    }
+
+    @Test
+    fun resume_permissionNotGranted_returnsPermissionsNotGranted() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        val permission = "android.permission.SCENE_UNDERSTANDING"
+        shadowOf(activity).denyPermissions(permission)
+
+        val result = underTest.resume() as SessionResumePermissionsNotGranted
+
+        assertThat(result.permissions).containsExactly(permission)
+    }
+
+    // TODO(b/349859981): Add a test to verify update() calls the corresponding LifecycleManager
+    // method once FakeRuntime supports it.
+    @Test
+    fun update_emitsUpdatedState() =
+        runTest(testDispatcher) {
+            val underTest =
+                (Session.create(activity, testDispatcher) as SessionCreateSuccess).session
+            val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+            val timeSource = lifecycleManager.timeSource
+            val expectedDuration = 100.milliseconds
+
+            awaitNewCoreState(underTest, this)
+            val beforeTimeMark = underTest.state.value.timeMark
+            timeSource += expectedDuration
+            // By default FakeLifecycleManager will only allow one call to update() to go through.
+            // Since
+            // we are calling update() twice, we need to allow one more call to go through.
+            lifecycleManager.allowOneMoreCallToUpdate()
+            awaitNewCoreState(underTest, this)
+            val afterTimeMark = underTest.state.value.timeMark
+
+            val actualDuration = afterTimeMark - beforeTimeMark
+            assertThat(actualDuration).isEqualTo(expectedDuration)
+        }
+
+    @Test
+    fun update_extendsState() =
+        runTest(testDispatcher) {
+            val underTest =
+                (Session.create(activity, testDispatcher) as SessionCreateSuccess).session
+            val stateExtender = underTest.stateExtenders.first() as FakeStateExtender
+            check(stateExtender.extended.isEmpty())
+
+            awaitNewCoreState(underTest, this)
+
+            assertThat(stateExtender.extended).isNotEmpty()
+        }
+
+    @Test
+    fun pause_setsLifecycleToPaused() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        underTest.resume()
+
+        underTest.pause()
+
+        val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+        assertThat(lifecycleManager.state).isEqualTo(FakeLifecycleManager.State.PAUSED)
+    }
+
+    @Test
+    fun pause_destroyed_throwsIllegalStateException() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        underTest.destroy()
+
+        assertFailsWith<IllegalStateException> { underTest.pause() }
+    }
+
+    @Test
+    fun destroy_initialized_setsLifecycleToStopped() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+
+        underTest.destroy()
+
+        val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+        assertThat(lifecycleManager.state).isEqualTo(FakeLifecycleManager.State.STOPPED)
+    }
+
+    @Test
+    fun destroy_resumed_setsLifecycleToStopped() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        underTest.resume()
+
+        underTest.destroy()
+
+        val lifecycleManager = underTest.runtime.lifecycleManager as FakeLifecycleManager
+        assertThat(lifecycleManager.state).isEqualTo(FakeLifecycleManager.State.STOPPED)
+    }
+
+    @Test
+    fun destroy_cancelsCoroutineScope() {
+        val underTest = (Session.create(activity) as SessionCreateSuccess).session
+        // Creating a job that will not finish by the time destroy is called.
+        val job = underTest.coroutineScope.launch { delay(12.hours) }
+
+        underTest.destroy()
+
+        // The job should be cancelled iff destroy was called and the coroutine scope was cancelled.
+        assertThat(job.isCancelled).isTrue()
+    }
+
+    /** Resumes and pauses the session just enough to emit a new CoreState. */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private suspend fun awaitNewCoreState(session: Session, testScope: TestScope) {
+        session.resume()
+        testScope.advanceUntilIdle()
+        session.pause()
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/internal/HitResultTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/internal/HitResultTest.kt
new file mode 100644
index 0000000..81b9187
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/internal/HitResultTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.internal
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.math.Pose
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HitResultTest {
+
+    class TestTrackable : Trackable {
+        override fun createAnchor(pose: Pose): Anchor {
+            throw NotImplementedError("Not implemented")
+        }
+
+        override val trackingState: TrackingState = TrackingState.Stopped
+    }
+
+    @Test
+    fun equals_sameObject_returnsTrue() {
+        val underTest = HitResult(1.0f, Pose(), TestTrackable())
+
+        assertThat(underTest.equals(underTest)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsSameValues_returnsTrue() {
+        val distance = 1.0f
+        val pose = Pose()
+        val trackable = TestTrackable()
+        val underTest1 = HitResult(distance, pose, trackable)
+        val underTest2 = HitResult(distance, pose, trackable)
+
+        assertThat(underTest1.equals(underTest2)).isTrue()
+    }
+
+    @Test
+    fun equals_differentObjectsDifferentValues_returnsFalse() {
+        val underTest1 = HitResult(1.0f, Pose(), TestTrackable())
+        val underTest2 = HitResult(2.0f, Pose(), TestTrackable())
+
+        assertThat(underTest1.equals(underTest2)).isFalse()
+    }
+
+    @Test
+    fun hashCode_differentObjectsSameValues_returnsSameHashCode() {
+        val distance = 1.0f
+        val pose = Pose()
+        val trackable = TestTrackable()
+        val underTest1 = HitResult(distance, pose, trackable)
+        val underTest2 = HitResult(distance, pose, trackable)
+
+        assertThat(underTest1.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCode_differentObjectsDifferentValues_returnsDifferentHashCodes() {
+        val underTest1 = HitResult(1.0f, Pose(), TestTrackable())
+        val underTest2 = HitResult(2.0f, Pose(), TestTrackable())
+
+        assertThat(underTest1.hashCode()).isNotEqualTo(underTest2.hashCode())
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/CoroutinesTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/CoroutinesTest.kt
new file mode 100644
index 0000000..f97d743
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/CoroutinesTest.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.java
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.SessionCreateSuccess
+import com.google.common.truth.Truth.assertThat
+import kotlin.time.Duration.Companion.hours
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.guava.future
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class CoroutinesTest {
+    private lateinit var activity: Activity
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testScope: TestScope
+
+    @get:Rule val activityScenarioRule = ActivityScenarioRule<Activity>(Activity::class.java)
+
+    @Before
+    fun setUp() {
+        activityScenarioRule.scenario.onActivity { this.activity = it }
+        shadowOf(activity).grantPermissions("android.permission.SCENE_UNDERSTANDING")
+
+        testDispatcher = StandardTestDispatcher()
+        testScope = TestScope(testDispatcher)
+    }
+
+    @Test
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun toFuture_cancelsFutureWhenSessionIsDestroyed() =
+        runTest(testDispatcher) {
+            val session = (Session.create(activity, testDispatcher) as SessionCreateSuccess).session
+            var isCoroutineComplete = false
+
+            val future =
+                toFuture(session) {
+                    delay(1.hours)
+                    isCoroutineComplete = true
+                }
+            session.destroy()
+            testScope.advanceUntilIdle()
+
+            assertThat(future.isCancelled).isTrue()
+            assertThat(future.isDone).isTrue()
+            // Verify that the coroutine was cancelled and did not actually complete.
+            assertThat(isCoroutineComplete).isFalse()
+        }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/FlowsTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/FlowsTest.kt
new file mode 100644
index 0000000..8fbdb5f
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/java/FlowsTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.java
+
+import android.app.Activity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.Session
+import androidx.xr.runtime.SessionCreateSuccess
+import com.google.common.truth.Truth.assertThat
+import kotlin.time.Duration.Companion.hours
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class FlowsTest {
+    private lateinit var activity: Activity
+    private lateinit var testDispatcher: TestDispatcher
+    private lateinit var testScope: TestScope
+
+    @get:Rule val activityScenarioRule = ActivityScenarioRule<Activity>(Activity::class.java)
+
+    @Before
+    fun setUp() {
+        activityScenarioRule.scenario.onActivity { this.activity = it }
+        shadowOf(activity).grantPermissions("android.permission.SCENE_UNDERSTANDING")
+
+        testDispatcher = StandardTestDispatcher()
+        testScope = TestScope(testDispatcher)
+    }
+
+    @Test
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun toObservable_terminatesObservableWhenSessionIsDestroyed() =
+        runTest(testDispatcher) {
+            val session = (Session.create(activity, testDispatcher) as SessionCreateSuccess).session
+            var isTerminated = false
+
+            val observable =
+                toObservable(
+                        session,
+                        flow {
+                            emit(1)
+                            delay(1.hours)
+                            emit(2)
+                        },
+                    )
+                    .doOnTerminate() { isTerminated = true }
+            observable.subscribe()
+            session.destroy()
+            testScope.advanceUntilIdle()
+
+            assertThat(isTerminated).isTrue()
+        }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/FloatExtTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/FloatExtTest.kt
new file mode 100644
index 0000000..3471cef
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/FloatExtTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.PI
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FloatExtTest {
+    @Test
+    fun rsqrt_returnsReciprocalSqrt() {
+        assertThat(rsqrt(0.02f)).isWithin(1.0e-5f).of(7.0710683f)
+        assertThat(rsqrt(1f)).isWithin(1.0e-5f).of(1f)
+        assertThat(rsqrt(2f)).isWithin(1.0e-5f).of(0.70710677f)
+        assertThat(rsqrt(3f)).isWithin(1.0e-5f).of(0.57735026f)
+    }
+
+    @Test
+    fun clamp_returnsValueBetweenMinAndMax() {
+        assertThat(clamp(0f, 1f, 2f)).isEqualTo(1f)
+        assertThat(clamp(3f, 1f, 2f)).isEqualTo(2f)
+        assertThat(clamp(2f, 1f, 3f)).isEqualTo(2f)
+    }
+
+    @Test
+    fun lerpFloat_returnsInterpolatedValueBetweenStartAndEnd() {
+        assertThat(lerp(1f, 2f, 0f)).isEqualTo(1f)
+        assertThat(lerp(1f, 2f, 0.5f)).isEqualTo(1.5f)
+        assertThat(lerp(1f, 2f, 1f)).isEqualTo(2f)
+    }
+
+    @Test
+    fun toDegrees_returnsDegreesFromRadians() {
+        assertThat(toDegrees(PI.toFloat() / 1f)).isEqualTo(180f)
+        assertThat(toDegrees(PI.toFloat() / 2f)).isEqualTo(90f)
+        assertThat(toDegrees(PI.toFloat() / 3f)).isEqualTo(60f)
+        assertThat(toDegrees(PI.toFloat() / 4f)).isEqualTo(45f)
+        assertThat(toDegrees(PI.toFloat() / 6f)).isEqualTo(30f)
+    }
+
+    @Test
+    fun toRadians_returnsRadiansFromDegrees() {
+        assertThat(toRadians(180f)).isEqualTo(PI.toFloat() / 1f)
+        assertThat(toRadians(90f)).isEqualTo(PI.toFloat() / 2f)
+        assertThat(toRadians(60f)).isEqualTo(PI.toFloat() / 3f)
+        assertThat(toRadians(45f)).isEqualTo(PI.toFloat() / 4f)
+        assertThat(toRadians(30f)).isEqualTo(PI.toFloat() / 6f)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Matrix4Test.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Matrix4Test.kt
new file mode 100644
index 0000000..18b9f57
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Matrix4Test.kt
@@ -0,0 +1,801 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class Matrix4Test {
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+
+        assertThat(underTest.toString())
+            .isEqualTo(
+                "\n[ " +
+                    1.0 +
+                    "\t" +
+                    5.0 +
+                    "\t" +
+                    9.0 +
+                    "\t" +
+                    13.0 +
+                    "\n  " +
+                    2.0 +
+                    "\t" +
+                    6.0 +
+                    "\t" +
+                    10.0 +
+                    "\t" +
+                    14.0 +
+                    "\n  " +
+                    3.0 +
+                    "\t" +
+                    7.0 +
+                    "\t" +
+                    11.0 +
+                    "\t" +
+                    15.0 +
+                    "\n  " +
+                    4.0 +
+                    "\t" +
+                    8.0 +
+                    "\t" +
+                    12.0 +
+                    "\t" +
+                    16.0 +
+                    " ]"
+            )
+    }
+
+    @Test
+    fun constructor_fromMatrix4_returnsSameValues() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 = underTest
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 =
+            Matrix4(
+                floatArrayOf(
+                    9f,
+                    10f,
+                    11f,
+                    12f,
+                    13f,
+                    14f,
+                    15f,
+                    16f,
+                    17f,
+                    18f,
+                    19f,
+                    20f,
+                    21f,
+                    22f,
+                    23f,
+                    24f
+                )
+            )
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentObjects_returnsFalse() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 = Vector3()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 =
+            Matrix4(
+                floatArrayOf(
+                    9f,
+                    10f,
+                    11f,
+                    12f,
+                    13f,
+                    14f,
+                    15f,
+                    16f,
+                    17f,
+                    18f,
+                    19f,
+                    20f,
+                    21f,
+                    22f,
+                    23f,
+                    24f
+                )
+            )
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentObjects_returnsFalse() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 = Vector3()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun inverse_returnsInverseMatrix1() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val expected =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestInverse = underTest.inverse
+
+        assertThat(underTestInverse.data).isEqualTo(expected.data)
+    }
+
+    @Test
+    fun inverse_returnsInverseMatrix2() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 5f, -4f, 3f, 1f))
+        val expected =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, -5f, 4f, -3f, 1f))
+        val underTestInverse = underTest.inverse
+
+        assertThat(underTestInverse.data).isEqualTo(expected.data)
+    }
+
+    @Test
+    fun inverse_returnsInverseMatrix3() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 2f, 0f, 0f, 0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f))
+        val expected =
+            Matrix4(
+                floatArrayOf(1f, 0f, 0f, 0f, 0f, 0.5f, 0f, 0f, 0f, 0f, 0.25f, 0f, 0f, 0f, 0f, 1f)
+            )
+        val underTestInverse = underTest.inverse
+
+        assertThat(underTestInverse.data).isEqualTo(expected.data)
+    }
+
+    @Test
+    fun transpose_returnsTransposeMatrix1() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val expected =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestTranspose = underTest.transpose
+
+        assertThat(underTestTranspose.data).isEqualTo(expected.data)
+    }
+
+    @Test
+    fun transpose_returnsTransposeMatrix2() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 5f, 9f, 13f, 2f, 6f, 10f, 14f, 3f, 7f, 11f, 15f, 4f, 8f, 12f, 16f)
+            )
+        val expected =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTestTranspose = underTest.transpose
+
+        assertThat(underTestTranspose.data).isEqualTo(expected.data)
+    }
+
+    @Test
+    fun translation_returnsTranslationVector1() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestTranslation = underTest.translation
+
+        assertThat(underTestTranslation).isEqualTo(Vector3(0f, 0f, 0f))
+    }
+
+    @Test
+    fun translation_returnsTranslationVector2() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 5f, -4f, 3f, 1f))
+        val underTestTranslation = underTest.translation
+
+        assertThat(underTestTranslation).isEqualTo(Vector3(5f, -4f, 3f))
+    }
+
+    @Test
+    fun translation_returnsTranslationVector3() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 2f, 0f, 1f))
+        val underTestTranslation = underTest.translation
+
+        assertThat(underTestTranslation).isEqualTo(Vector3(0f, 2f, 0f))
+    }
+
+    @Test
+    fun scale_returnsScaleVector1() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestScale = underTest.scale
+
+        assertThat(underTestScale).isEqualTo(Vector3(1f, 1f, 1f))
+    }
+
+    @Test
+    fun scale_returnsScaleVector2() {
+        val underTest =
+            Matrix4(floatArrayOf(5f, 0f, 0f, 0f, 0f, -4f, 0f, 0f, 0f, 0f, 3f, 0f, 0f, 0f, 0f, 1f))
+        val underTestScale = underTest.scale
+
+        assertThat(underTestScale).isEqualTo(Vector3(5f, -4f, 3f))
+    }
+
+    @Test
+    fun scale_returnsScaleVector3() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 2f, 0f, 0f, 0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f))
+        val underTestScale = underTest.scale
+
+        assertThat(underTestScale).isEqualTo(Vector3(1f, 2f, 4f))
+    }
+
+    @Test
+    fun scale_returnsScaleVector4() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(
+                    0.707f,
+                    0.707f,
+                    0f,
+                    0f,
+                    -0.707f,
+                    0.707f,
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    1f,
+                    2f,
+                    5f,
+                    3f,
+                    1f,
+                    1f,
+                )
+            )
+        val underTestScale = underTest.scale
+
+        assertThat(underTestScale.x).isWithin(1e-3f).of(1f)
+        assertThat(underTestScale.y).isWithin(1e-3f).of(1f)
+        assertThat(underTestScale.z).isWithin(1e-3f).of(1f)
+    }
+
+    @Test
+    fun scale_returnsScaleVector5() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(
+                    0.707f,
+                    -0.5f,
+                    0.5f,
+                    0f,
+                    0.612f,
+                    0.707f,
+                    0.354f,
+                    0f,
+                    -0.354f,
+                    0.5f,
+                    -0.793f,
+                    0f,
+                    2.5f,
+                    1.8f,
+                    3.1f,
+                    1f,
+                )
+            )
+
+        val underTestScale = underTest.scale
+
+        assertThat(underTestScale.x).isWithin(1e-3f).of(1.0f)
+        assertThat(underTestScale.y).isWithin(1e-3f).of(1.0f)
+        assertThat(underTestScale.z).isWithin(1e-3f).of(-1.002f)
+    }
+
+    @Test
+    fun rotation_returnsRotationQuaternion1() {
+        val underTest =
+            Matrix4(floatArrayOf(0f, 1f, 0f, 0f, -1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestRotation = underTest.rotation
+
+        assertThat(underTestRotation.x).isWithin(1e-5f).of(0f)
+        assertThat(underTestRotation.y).isWithin(1e-5f).of(0f)
+        assertThat(underTestRotation.z).isWithin(1e-5f).of(0.70711f)
+        assertThat(underTestRotation.w).isWithin(1e-5f).of(0.70711f)
+    }
+
+    @Test
+    fun rotation_returnsRotationQuaternion2() {
+        val underTest =
+            Matrix4(floatArrayOf(5f, 1f, 3f, 0f, -1f, 6f, 0f, 0f, 0f, 0f, 1f, 3f, 1f, 0f, 0f, 1f))
+        val underTestRotation = underTest.rotation
+
+        assertThat(underTestRotation.x).isWithin(1e-5f).of(0f)
+        assertThat(underTestRotation.y).isWithin(1e-5f).of(-0.22237f)
+        assertThat(underTestRotation.z).isWithin(1e-5f).of(0.14825f)
+        assertThat(underTestRotation.w).isWithin(1e-5f).of(0.96362f)
+    }
+
+    @Test
+    fun rotation_returnsRotationQuaternion3() {
+        val underTest =
+            Matrix4(floatArrayOf(5f, 1f, 3f, 2f, -1f, 6f, 4f, 0f, 2f, 0f, 1f, 3f, 1f, 2f, 3f, 4f))
+        val underTestRotation = underTest.rotation
+
+        assertThat(underTestRotation.x).isWithin(1e-5f).of(0.29019f)
+        assertThat(underTestRotation.y).isWithin(1e-5f).of(-0.07254f)
+        assertThat(underTestRotation.z).isWithin(1e-5f).of(0.14509f)
+        assertThat(underTestRotation.w).isWithin(1e-5f).of(0.94312f)
+    }
+
+    @Test
+    fun pose_returnsTranslationRotationPose1() {
+        val underTest =
+            Matrix4(floatArrayOf(0f, 1f, 0f, 0f, -1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTestPose = underTest.pose
+
+        assertThat(underTestPose.translation.x).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.translation.y).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.translation.z).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.rotation.x).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.rotation.y).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.rotation.z).isWithin(1e-5f).of(0.70711f)
+        assertThat(underTestPose.rotation.w).isWithin(1e-5f).of(0.70711f)
+    }
+
+    @Test
+    fun pose_returnsTranslationRotationPose2() {
+        val underTest =
+            Matrix4(floatArrayOf(5f, 1f, 3f, 0f, -1f, 6f, 0f, 0f, 0f, 0f, 1f, 3f, 1f, 0f, 0f, 1f))
+        val underTestPose = underTest.pose
+
+        assertThat(underTestPose.translation.x).isWithin(1e-5f).of(1f)
+        assertThat(underTestPose.translation.y).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.translation.z).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.rotation.x).isWithin(1e-5f).of(0f)
+        assertThat(underTestPose.rotation.y).isWithin(1e-5f).of(-0.22237f)
+        assertThat(underTestPose.rotation.z).isWithin(1e-5f).of(0.14825f)
+        assertThat(underTestPose.rotation.w).isWithin(1e-5f).of(0.96362f)
+    }
+
+    @Test
+    fun pose_returnsTranslationRotationPose3() {
+        val underTest =
+            Matrix4(floatArrayOf(5f, 1f, 3f, 2f, -1f, 6f, 4f, 0f, 2f, 0f, 1f, 3f, 1f, 2f, 3f, 4f))
+        val underTestPose = underTest.pose
+
+        assertThat(underTestPose.translation.x).isWithin(1e-5f).of(1f)
+        assertThat(underTestPose.translation.y).isWithin(1e-5f).of(2f)
+        assertThat(underTestPose.translation.z).isWithin(1e-5f).of(3f)
+        assertThat(underTestPose.rotation.x).isWithin(1e-5f).of(0.29019f)
+        assertThat(underTestPose.rotation.y).isWithin(1e-5f).of(-0.07255f)
+        assertThat(underTestPose.rotation.z).isWithin(1e-5f).of(0.14509f)
+        assertThat(underTestPose.rotation.w).isWithin(1e-5f).of(0.94312f)
+    }
+
+    @Test
+    fun multiply_returnsMultipliedMatrix1() {
+        val underTest =
+            Matrix4(floatArrayOf(2f, 0f, 5f, 0f, 3f, 4f, 5f, 0f, 4f, 6f, 1f, 4f, 0f, 5f, 5f, 0f))
+        val underTest2 =
+            Matrix4(floatArrayOf(1f, 0f, 7f, 5f, 0f, 3f, 2f, 2f, 6f, 5f, 4f, 0f, 2f, 0f, 4f, 0f))
+
+        assertThat(underTest * underTest2)
+            .isEqualTo(
+                Matrix4(
+                    floatArrayOf(
+                        30f,
+                        67f,
+                        37f,
+                        28f,
+                        17f,
+                        34f,
+                        27f,
+                        8f,
+                        43f,
+                        44f,
+                        59f,
+                        16f,
+                        20f,
+                        24f,
+                        14f,
+                        16f,
+                    )
+                )
+            )
+    }
+
+    @Test
+    fun multiply_returnsMultipliedMatrix2() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+        val underTest2 =
+            Matrix4(
+                floatArrayOf(1f, 5f, 9f, 13f, 2f, 6f, 10f, 14f, 3f, 7f, 11f, 15f, 4f, 8f, 12f, 16f)
+            )
+
+        assertThat(underTest * underTest2)
+            .isEqualTo(
+                Matrix4(
+                    floatArrayOf(
+                        1f,
+                        5f,
+                        9f,
+                        13f,
+                        2f,
+                        6f,
+                        10f,
+                        14f,
+                        3f,
+                        7f,
+                        11f,
+                        15f,
+                        4f,
+                        8f,
+                        12f,
+                        16f
+                    )
+                )
+            )
+    }
+
+    @Test
+    fun multiply_returnsMultipliedMatrix3() {
+        val underTest =
+            Matrix4(floatArrayOf(1f, 3f, 1f, 3f, 2f, 3f, 2f, 3f, 3f, 3f, 3f, 3f, 4f, 3f, 4f, 3f))
+        val underTest2 =
+            Matrix4(floatArrayOf(1f, 2f, 1f, 2f, 2f, 2f, 2f, 2f, 3f, 2f, 3f, 2f, 4f, 2f, 4f, 2f))
+
+        assertThat(underTest * underTest2)
+            .isEqualTo(
+                Matrix4(
+                    floatArrayOf(
+                        16f,
+                        18f,
+                        16f,
+                        18f,
+                        20f,
+                        24f,
+                        20f,
+                        24f,
+                        24f,
+                        30f,
+                        24f,
+                        30f,
+                        28f,
+                        36f,
+                        28f,
+                        36f,
+                    )
+                )
+            )
+    }
+
+    @Test
+    fun copy_returnsCopyOfMatrix() {
+        val underTest =
+            Matrix4(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+        val underTest2 = underTest.copy()
+
+        assertThat(underTest2).isEqualTo(underTest)
+    }
+
+    @Test
+    fun isTrs_returnsTrueIfTransformationMatrixIsValid() {
+        assertThat(
+                Matrix4(
+                        floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)
+                    )
+                    .isTrs
+            )
+            .isTrue()
+    }
+
+    @Test
+    fun isTrs_returnsFalseIfTransformationMatrixIsNotValid() {
+        assertThat(
+                Matrix4(
+                        floatArrayOf(0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f)
+                    )
+                    .isTrs
+            )
+            .isFalse()
+    }
+
+    @Test
+    fun fromTrs_returnsNewTransformationMatrix1() {
+        val underTest =
+            Matrix4.fromTrs(Vector3(0f, 0f, 0f), Quaternion(0f, 0f, 0f, 1f), Vector3(1f, 1f, 1f))
+        val expected =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromTrs_returnsNewTransformationMatrix2() {
+        val underTest =
+            Matrix4.fromTrs(
+                Vector3(0f, 0f, 0f),
+                Quaternion(0f, 0f, 0.70710678f, 0.70710678f),
+                Vector3(1f, 1f, 1f),
+            )
+        val expected =
+            Matrix4(floatArrayOf(0f, 1f, 0f, 0f, -1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromTrs_returnsNewTransformationMatrix3() {
+        val underTest =
+            Matrix4.fromTrs(Vector3(0f, 0f, 0f), Quaternion(0f, 0f, 0f, 1f), Vector3(2f, 0.5f, 3f))
+        val expected =
+            Matrix4(floatArrayOf(2f, 0f, 0f, 0f, 0f, 0.5f, 0f, 0f, 0f, 0f, 3f, 0f, 0f, 0f, 0f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromTranslation_returnsNewTranslationMatrix1() {
+        val underTest = Matrix4.fromTranslation(Vector3(2f, 3f, 4f))
+
+        assertThat(underTest.data)
+            .isEqualTo(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 2f, 3f, 4f, 1f))
+    }
+
+    @Test
+    fun fromTranslation_returnsNewTranslationMatrix2() {
+        val underTest = Matrix4.fromTranslation(Vector3(-2f, -3f, -4f))
+
+        assertThat(underTest.data)
+            .isEqualTo(
+                floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, -2f, -3f, -4f, 1f)
+            )
+    }
+
+    @Test
+    fun fromScale_returnsNewScaleMatrix1() {
+        val underTest = Matrix4.fromScale(Vector3(2f, 3f, 4f))
+
+        assertThat(underTest.data)
+            .isEqualTo(floatArrayOf(2f, 0f, 0f, 0f, 0f, 3f, 0f, 0f, 0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f))
+    }
+
+    @Test
+    fun fromScale_returnsNewScaleMatrix2() {
+        val underTest = Matrix4.fromScale(Vector3(-2f, -3f, -4f))
+
+        assertThat(underTest.data)
+            .isEqualTo(
+                floatArrayOf(-2f, 0f, 0f, 0f, 0f, -3f, 0f, 0f, 0f, 0f, -4f, 0f, 0f, 0f, 0f, 1f)
+            )
+    }
+
+    @Test
+    fun fromScaleFloat_returnsNewScaleMatrix1() {
+        val underTest = Matrix4.fromScale(2f)
+
+        assertThat(underTest.data)
+            .isEqualTo(floatArrayOf(2f, 0f, 0f, 0f, 0f, 2f, 0f, 0f, 0f, 0f, 2f, 0f, 0f, 0f, 0f, 1f))
+    }
+
+    @Test
+    fun fromScaleFloat_returnsNewScaleMatrix2() {
+        val underTest = Matrix4.fromScale(-2f)
+
+        assertThat(underTest.data)
+            .isEqualTo(
+                floatArrayOf(-2f, 0f, 0f, 0f, 0f, -2f, 0f, 0f, 0f, 0f, -2f, 0f, 0f, 0f, 0f, 1f)
+            )
+    }
+
+    @Test
+    fun fromQuaternion_returnsNewRotationMatrix1() {
+        val underTest = Matrix4.fromQuaternion(Quaternion(0f, 0.7071f, 0f, 0.7071f))
+        val expected =
+            Matrix4(floatArrayOf(0f, 0f, -1f, 0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 0f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromQuaternion_returnsNewRotationMatrix2() {
+        val underTest = Matrix4.fromQuaternion(Quaternion(0.7071f, 0f, 0.7071f, 0f))
+        val expected =
+            Matrix4(floatArrayOf(0f, 0f, 1f, 0f, 0f, -1f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 0f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromQuaternion_returnsNewRotationMatrix3() {
+        val underTest = Matrix4.fromQuaternion(Quaternion(0f, -0.38f, 0.92f, 0f))
+        val expected =
+            Matrix4(
+                floatArrayOf(
+                    -1f,
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    -0.70852f,
+                    -0.70569f,
+                    0f,
+                    0f,
+                    -0.70569f,
+                    0.70852f,
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    1f,
+                )
+            )
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromPose_returnsNewTransformationMatrix1() {
+        val underTest =
+            Matrix4.fromPose(Pose(Vector3(0f, 0f, 0f), Quaternion(0f, -0.38f, 0.92f, 0f)))
+        val expected =
+            Matrix4(
+                floatArrayOf(
+                    -1f,
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    -0.70852f,
+                    -0.70569f,
+                    0f,
+                    0f,
+                    -0.70569f,
+                    0.70852f,
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    1f,
+                )
+            )
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromPose_returnsNewTransformationMatrix2() {
+        val underTest = Matrix4.fromPose(Pose(Vector3(2f, 3f, 4f), Quaternion(0f, 0f, 0f, 1f)))
+        val expected =
+            Matrix4(floatArrayOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 2f, 3f, 4f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    @Test
+    fun fromPose_returnsNewTransformationMatrix3() {
+        val underTest =
+            Matrix4.fromPose(Pose(Vector3(5f, 6f, 7f), Quaternion(0f, 0.7071f, 0f, 0.7071f)))
+        val expected =
+            Matrix4(floatArrayOf(0f, 0f, -1f, 0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 0f, 5f, 6f, 7f, 1f))
+
+        assertMatrix(underTest, expected)
+    }
+
+    private fun assertMatrix(matrix: Matrix4, expected: Matrix4) {
+        assertThat(matrix.data.size).isEqualTo(expected.data.size)
+        for (i in matrix.data.indices) {
+            assertThat(matrix.data[i]).isWithin(1e-5f).of(expected.data[i])
+        }
+    }
+
+    @Test
+    fun data_returnsFloatArrayOfMatrixComponents() {
+        val underTest =
+            Matrix4(
+                    floatArrayOf(
+                        1f,
+                        2f,
+                        3f,
+                        4f,
+                        5f,
+                        6f,
+                        7f,
+                        8f,
+                        9f,
+                        10f,
+                        11f,
+                        12f,
+                        13f,
+                        14f,
+                        15f,
+                        16f
+                    )
+                )
+                .data
+
+        assertThat(underTest)
+            .isEqualTo(
+                floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 10f, 11f, 12f, 13f, 14f, 15f, 16f)
+            )
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/PoseTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/PoseTest.kt
new file mode 100644
index 0000000..bd9f755
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/PoseTest.kt
@@ -0,0 +1,386 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.sqrt
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class PoseTest {
+
+    @Test
+    fun constructor_noArguments_returnsZeroVectorAndIdentityQuaternion() {
+        val underTest = Pose()
+
+        assertThat(underTest.translation).isEqualTo(Vector3(0f, 0f, 0f))
+        assertThat(underTest.rotation).isEqualTo(Quaternion(0f, 0f, 0f, 1f))
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 =
+            Pose(translation = Vector3(9f, 10f, 11f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest3 = Vector3()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 =
+            Pose(translation = Vector3(9f, 10f, 11f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest3 = Vector3()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 = Pose()
+
+        assertThat(underTest.toString())
+            .isEqualTo(
+                "Pose{\n\tTranslation=[x=1.0, y=2.0, z=3.0]\n\tRotation=[x=0.35634834, y=0.4454354, z=0.5345225, w=0.6236096]\n}"
+            )
+        assertThat(underTest2.toString())
+            .isEqualTo(
+                "Pose{\n\tTranslation=[x=0.0, y=0.0, z=0.0]\n\tRotation=[x=0.0, y=0.0, z=0.0, w=1.0]\n}"
+            )
+    }
+
+    @Test
+    fun distance_returnsLengthOfVectorBetweenTranslations() {
+        val underTest = Pose(translation = Vector3(0F, 3f, 4F), rotation = Quaternion())
+
+        // (0, 3, 4) - (0, 0, 0) = (0, 3, 4) -> sqrt(0^2 + 3^2 + 4^2) = sqrt(25)
+        assertThat(Pose.distance(underTest, Pose())).isEqualTo(5F)
+    }
+
+    @Test
+    fun constructor_fromPose_returnsSameValues() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 = Pose(underTest)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun compose_returnsPoseWithTranslationAndRotation() {
+        val underTest =
+            Pose(
+                translation = Vector3(3f, 0f, 0f),
+                rotation = Quaternion(0f, sqrt(2f) / 2, 0f, sqrt(2f) / 2),
+            )
+        val underTest2 =
+            Pose(
+                translation = Vector3(0f, 0f, -3f),
+                rotation = Quaternion(0f, sqrt(2f) / 2, 0f, sqrt(2f) / 2),
+            )
+
+        val underTestCompose = underTest.compose(underTest2)
+
+        assertTranslation(underTestCompose.translation, 0f, 0f, 0f)
+        assertRotation(underTestCompose.rotation, 0f, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun translate_withZeroVector3_returnsSamePose() {
+        val underTest = Pose(translation = Vector3(1f, 2f, 3f))
+        val translation = Vector3.Zero
+
+        val translatedPose = underTest.translate(translation)
+
+        assertTranslation(translatedPose.translation, 1f, 2f, 3f)
+        assertThat(translatedPose.rotation).isEqualTo(underTest.rotation)
+    }
+
+    @Test
+    fun translate_withVector3_returnsTranslatedPose() {
+        val underTest = Pose(translation = Vector3(1f, 2f, 3f))
+        val translation = Vector3(1f, 1f, 1f)
+
+        val translatedPose = underTest.translate(translation)
+
+        assertTranslation(translatedPose.translation, 2f, 3f, 4f)
+        assertThat(translatedPose.rotation).isEqualTo(underTest.rotation)
+    }
+
+    @Test
+    fun rotate_withIdentityQuaternion_returnsSamePose() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(1f, 2f, 3f, 4f))
+        val rotation = Quaternion.Identity
+
+        val rotatedPose = underTest.rotate(rotation)
+
+        assertTranslation(rotatedPose.translation, 1f, 2f, 3f)
+        assertRotation(
+            rotatedPose.rotation,
+            underTest.rotation.x,
+            underTest.rotation.y,
+            underTest.rotation.z,
+            underTest.rotation.w,
+        )
+    }
+
+    @Test
+    fun rotate_withQuaternion_returnsRotatedPose() {
+        val underTest = Pose(rotation = Quaternion.Identity)
+        val rotation = Quaternion.fromAxisAngle(Vector3.Forward, 180f)
+
+        val rotatedPose = underTest.rotate(rotation)
+
+        assertThat(rotatedPose.translation).isEqualTo(underTest.translation)
+        assertRotation(rotatedPose.rotation, rotation.x, rotation.y, rotation.z, rotation.w)
+    }
+
+    @Test
+    fun rotate_withNonIdentityQuaternion_returnsRotatedPose() {
+        // A pose with a rotation of 45 degrees around the Y-axis.
+        val underTest = Pose(rotation = Quaternion(0f, sqrt(2f) / 2, 0f, sqrt(2f) / 2))
+        // A Quaternion representing a 45-degree rotation around the Y-axis.
+        val rotation = Quaternion(0f, sqrt(2f) / 2, 0f, sqrt(2f) / 2)
+
+        val rotatedPose = underTest.rotate(rotation)
+
+        assertThat(rotatedPose.translation).isEqualTo(underTest.translation)
+        // The rotation of the rotated Pose is equal to a 90-degree rotation around the Y-axis.
+        assertRotation(rotatedPose.rotation, 0f, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun inverse_returnsPoseWithOppositeTransformation() {
+        val underTest =
+            Pose(
+                translation = Vector3(3f, 0f, 0f),
+                rotation = Quaternion(0f, sqrt(2f) / 2, 0f, sqrt(2f) / 2),
+            )
+
+        val underTestInverted = underTest.inverse
+
+        assertTranslation(underTestInverted.translation, 0f, 0f, -3f)
+        assertRotation(underTestInverted.rotation, 0f, -sqrt(2f) / 2, 0f, sqrt(2f) / 2)
+    }
+
+    @Test
+    fun transform_returnsTransformedPointByPose() {
+        val underTest =
+            Pose(translation = Vector3(0f, 0f, 1f), rotation = Quaternion(0f, 0.7071f, 0f, 0.7071f))
+        val point = Vector3(0f, 0f, 1f)
+
+        val transformedPoint = underTest.transformPoint(point)
+
+        assertTranslation(transformedPoint, 1f, 0f, 1f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedPose() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 =
+            Pose(translation = Vector3(4f, 5f, 6f), rotation = Quaternion(8f, 9f, 10f, 11f))
+
+        val interpolatedPose = Pose.lerp(underTest, underTest2, 0.5f)
+
+        assertTranslation(interpolatedPose.translation, 2.5f, 3.5f, 4.5f)
+        assertRotation(interpolatedPose.rotation, 0.38759f, 0.45833f, 0.52907f, 0.59981f)
+    }
+
+    @Test
+    fun up_returnsUpVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0.7071f, 0f, 0.7071f))
+
+        assertTranslation(underTest.up, 0f, 1f, 0f)
+    }
+
+    @Test
+    fun up_returnsUpVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.up, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun down_returnsDownVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0.7071f, 0f, 0.7071f))
+
+        assertTranslation(underTest.down, 0f, -1f, 0f)
+    }
+
+    @Test
+    fun down_returnsDownVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.down, 0f, 0f, -1f)
+    }
+
+    @Test
+    fun left_returnsLeftVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.left, -1f, 0f, 0f)
+    }
+
+    @Test
+    fun left_returnsLeftVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0.7071f, 0f, 0.7071f))
+
+        assertTranslation(underTest.left, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun right_returnsRightVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.right, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun right_returnsRightVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0.7071f, 0f, 0.7071f))
+
+        assertTranslation(underTest.right, 0f, 0f, -1f)
+    }
+
+    @Test
+    fun forward_returnsForwardVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0f, 0.7071f, 0.7071f))
+
+        assertTranslation(underTest.forward, 0f, 0f, -1f)
+    }
+
+    @Test
+    fun forward_returnsForwardVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.forward, 0f, 1f, 0f)
+    }
+
+    @Test
+    fun backward_returnsBackwardVectorInLocalCoordinateSystem1() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0f, 0f, 0.7071f, 0.7071f))
+
+        assertTranslation(underTest.backward, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun backward_returnsBackwardVectorInLocalCoordinateSystem2() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.7071f, 0f, 0f, 0.7071f))
+
+        assertTranslation(underTest.backward, 0f, -1f, 0f)
+    }
+
+    @Test
+    fun transformVector_returnsVectorTransformedByPose() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(1f, 2f, 3f, 4f))
+        val vector = Vector3(1f, 2f, 3f)
+        val underTestRotated = underTest.transformVector(vector)
+
+        assertTranslation(underTestRotated, 1f, 2f, 3f)
+    }
+
+    @Test
+    fun copy_returnsCopyOfPose() {
+        val underTest =
+            Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(4f, 5f, 6f, 7f))
+        val underTest2 = underTest.copy()
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun fromLookAt_returnsPoseLookingAtTarget() {
+        val underTest =
+            Pose.fromLookAt(
+                eye = Vector3.Zero,
+                target = Vector3(0f, 0f, 10f),
+                up = Vector3(0f, 1f, 0f)
+            )
+
+        assertTranslation(underTest.translation, 0f, 0f, 0f)
+        assertRotation(underTest.rotation, 0f, 0f, 0f, 1f)
+    }
+
+    private fun assertTranslation(
+        translation: Vector3,
+        expectedX: Float,
+        expectedY: Float,
+        expectedZ: Float,
+    ) {
+        assertThat(translation.x).isWithin(1.0e-4f).of(expectedX)
+        assertThat(translation.y).isWithin(1.0e-4f).of(expectedY)
+        assertThat(translation.z).isWithin(1.0e-4f).of(expectedZ)
+    }
+
+    private fun assertRotation(
+        rotation: Quaternion,
+        expectedX: Float,
+        expectedY: Float,
+        expectedZ: Float,
+        expectedW: Float,
+    ) {
+        assertThat(rotation.x).isWithin(1.0e-4f).of(expectedX)
+        assertThat(rotation.y).isWithin(1.0e-4f).of(expectedY)
+        assertThat(rotation.z).isWithin(1.0e-4f).of(expectedZ)
+        assertThat(rotation.w).isWithin(1.0e-4f).of(expectedW)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/QuaternionTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/QuaternionTest.kt
new file mode 100644
index 0000000..660cc98
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/QuaternionTest.kt
@@ -0,0 +1,498 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.sqrt
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class QuaternionTest {
+
+    @Test
+    fun constructor_noArguments_returnsIdentityQuaternion() {
+        val underTest = Quaternion()
+        assertRotation(underTest, 0f, 0f, 0f, 1f)
+
+        val underTest2 = Quaternion.Identity
+        assertRotation(underTest2, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(1f, 2f, 3f, 4f)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(9f, 10f, 11f, 12f)
+        val underTest3 = Vector2()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(1f, 2f, 3f, 4f)
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(9f, 10f, 11f, 12f)
+        val underTest3 = Quaternion()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest = Quaternion(0f, 1f, 2f, 0f)
+        val underTest2 = Quaternion()
+
+        assertThat(underTest.toString()).isEqualTo("[x=0.0, y=0.4472136, z=0.8944272, w=0.0]")
+        assertThat(underTest2.toString()).isEqualTo("[x=0.0, y=0.0, z=0.0, w=1.0]")
+    }
+
+    @Test
+    fun constructor_fromQuaternion_returnsSameValues() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(underTest)
+
+        assertRotation(underTest, underTest2.x, underTest2.y, underTest2.z, underTest2.w)
+    }
+
+    @Test
+    fun constructor_fromVector4_returnsSameValues() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(underTest)
+        val length =
+            sqrt(
+                underTest.x * underTest.x +
+                    underTest.y * underTest.y +
+                    underTest.z * underTest.z +
+                    underTest.w * underTest.w
+            )
+
+        assertRotation(
+            underTest2,
+            underTest.x / length,
+            underTest.y / length,
+            underTest.z / length,
+            underTest.w / length,
+        )
+    }
+
+    @Test
+    fun axisAngle_returnsQuaternionFromAxis() {
+        val underTestAxis = Vector3(1f, 0f, 0f)
+        val degrees = 180f
+
+        // angle = 1/2 * 180 * pi / 180 = 1/2 * pi
+        // sin(angle) = sin(1/2 pi) = 1
+        // cos(angle) = cos(1/2 pi) = 0
+        // returns Quaternion(1 * 1, 1 * 0, 1 * 0, 0)
+        val underTestAxisAngle = Quaternion.fromAxisAngle(underTestAxis, degrees)
+
+        assertRotation(underTestAxisAngle, 1f, 0f, 0f, 0f)
+    }
+
+    @Test
+    fun quaternion_fromEulerAngles1() {
+        val eulerAngles = Vector3(180f, 0f, 0f)
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(eulerAngles)
+
+        assertRotation(underTestYawPitchRoll, 1f, 0f, 0f, 0f)
+    }
+
+    @Test
+    fun quaternion_fromEulerAngles2() {
+        val eulerAngles = Vector3(0f, 0f, 0f)
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(eulerAngles)
+
+        assertRotation(underTestYawPitchRoll, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun quaternion_fromEulerAngles3() {
+        val eulerAngles = Vector3(90f, 0f, 0f)
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(eulerAngles)
+
+        assertRotation(underTestYawPitchRoll, 0.7071f, 0f, 0f, 0.7071f)
+    }
+
+    @Test
+    fun quaternion_fromEulerAngles4() {
+        val eulerAngles = Vector3(0f, 180f, 0f)
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(eulerAngles)
+
+        assertRotation(underTestYawPitchRoll, 0f, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun quaternion_fromPitchYawRoll1() {
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(180f, 0f, 0f)
+
+        assertRotation(underTestYawPitchRoll, 1f, 0f, 0f, 0f)
+    }
+
+    @Test
+    fun quaternion_fromPitchYawRoll2() {
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(0f, 0f, 0f)
+
+        assertRotation(underTestYawPitchRoll, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun quaternion_fromPitchYawRoll3() {
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(90f, 0f, 0f)
+
+        assertRotation(underTestYawPitchRoll, 0.7071f, 0f, 0f, 0.7071f)
+    }
+
+    @Test
+    fun quaternion_fromPitchYawRoll4() {
+        val underTestYawPitchRoll = Quaternion.fromEulerAngles(0f, 180f, 0f)
+
+        assertRotation(underTestYawPitchRoll, 0f, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun normalized_returnsQuaternionNormalized() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTestNormalized = underTest.toNormalized()
+
+        assertRotation(
+            underTest,
+            underTestNormalized.x,
+            underTestNormalized.y,
+            underTestNormalized.z,
+            underTestNormalized.w,
+        )
+    }
+
+    @Test
+    fun inverted_returnsQuaternionInverted() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTestInverted = underTest.inverse
+
+        assertRotation(
+            underTestInverted,
+            -1 * underTest.x,
+            -1 * underTest.y,
+            -1 * underTest.z,
+            1 * underTest.w,
+        )
+    }
+
+    @Test
+    fun unaryMinus_returnsQuaternionWithSignsFlipped() {
+        val underTest = Quaternion(1f, -2f, 3f, -4f)
+        val underTestUnary = -underTest
+
+        assertRotation(
+            underTestUnary,
+            -1 * underTest.x,
+            -1 * underTest.y,
+            -1 * underTest.z,
+            -1 * underTest.w,
+        )
+    }
+
+    @Test
+    fun rotateVector_returnsVector3RotatedByQuaternion() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val vector = Vector3(1f, 2f, 3f)
+        val underTestRotated = underTest * vector
+
+        assertThat(underTestRotated.x).isWithin(1e-5f).of(1f)
+        assertThat(underTestRotated.y).isWithin(1e-5f).of(2f)
+        assertThat(underTestRotated.z).isWithin(1e-5f).of(3f)
+    }
+
+    @Test
+    fun times_returnsTwoQuaternionsMultiplied() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(5f, -6f, 7f, -8f)
+        val underTestTimes = underTest * underTest2
+
+        assertRotation(underTestTimes, 0.6090002f, -0.442909f, -0.16609f, -0.63668f)
+    }
+
+    @Test
+    fun div_returnsQuaternionDividedByScalar() {
+        val underTest = Quaternion(0f, 0f, 0f, 1f)
+        val underTestDiv = underTest / 2f
+
+        assertRotation(underTestDiv, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun interpolate_returnsInterpolatedQuaternion() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(2f, 4f, 6f, 8f)
+        val underTestInterpolate = Quaternion.lerp(underTest, underTest2, 0.5f)
+
+        assertRotation(underTestInterpolate, 0.18257418f, 0.36514837f, 0.5477226f, 0.73029673f)
+    }
+
+    @Test
+    fun sphericalInterpolate_returnsQuaternionInterpolatedBetweenQuaternions() {
+        val axis = Vector3(1f, 2f, 3f)
+        val ratio = 0.5f
+        val sourceAngle = 90f
+        val destinationAngle = 180f
+        val expectedAngle = sourceAngle + (destinationAngle - sourceAngle) * ratio
+        val sourceQuaternion = Quaternion.fromAxisAngle(axis, sourceAngle)
+        val destinationQuaternion = Quaternion.fromAxisAngle(axis, destinationAngle)
+        val expected = Quaternion.fromAxisAngle(axis, expectedAngle)
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, ratio)
+
+        assertRotation(slerpResult, expected.x, expected.y, expected.z, expected.w)
+    }
+
+    @Test
+    fun sphericalInterpolate_smallAngle_returnsQuaternionInterpolatedBetweenQuaternions() {
+        val axis = Vector3(1f, 2f, 3f)
+        val ratio = 0.5f
+        val sourceAngle = 90f
+        val destinationAngle = 90.01f
+        val expectedAngle = sourceAngle + (destinationAngle - sourceAngle) * ratio
+        val sourceQuaternion = Quaternion.fromAxisAngle(axis, sourceAngle)
+        val destinationQuaternion = Quaternion.fromAxisAngle(axis, destinationAngle)
+        val expected = Quaternion.fromAxisAngle(axis, expectedAngle)
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, ratio)
+
+        assertRotation(
+            slerpResult,
+            expected.x,
+            expected.y,
+            expected.z,
+            expected.w,
+            tolerance = 1.0e-5f
+        )
+    }
+
+    @Test
+    fun sphericalInterpolate_ratioAboveOne_returnsQuaternionExtrapolatedOutsideQuaternions() {
+        val axis = Vector3(1f, 2f, 3f)
+        val ratio = 2.0f
+        val sourceAngle = 90f
+        val destinationAngle = 100f
+        val expectedAngle = sourceAngle + (destinationAngle - sourceAngle) * ratio
+        val sourceQuaternion = Quaternion.fromAxisAngle(axis, sourceAngle)
+        val destinationQuaternion = Quaternion.fromAxisAngle(axis, destinationAngle)
+        val expected = Quaternion.fromAxisAngle(axis, expectedAngle)
+
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, ratio)
+
+        assertRotation(slerpResult, expected.x, expected.y, expected.z, expected.w)
+    }
+
+    @Test
+    fun sphericalInterpolate_ratioBelowZero_returnsQuaternionExtrapolatedOutsideQuaternions() {
+        val axis = Vector3(1f, 2f, 3f)
+        val ratio = -2.0f
+        val sourceAngle = 90f
+        val destinationAngle = 100f
+        val expectedAngle = sourceAngle + (destinationAngle - sourceAngle) * ratio
+        val sourceQuaternion = Quaternion.fromAxisAngle(axis, sourceAngle)
+        val destinationQuaternion = Quaternion.fromAxisAngle(axis, destinationAngle)
+        val expected = Quaternion.fromAxisAngle(axis, expectedAngle)
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, ratio)
+
+        assertRotation(slerpResult, expected.x, expected.y, expected.z, expected.w)
+    }
+
+    @Test
+    fun sphericalInterpolate_RatioZero_returnsSourceQuaternion() {
+        val sourceQuaternion = Quaternion(1f, 2f, 3f, 4f)
+        val destinationQuaternion = Quaternion(2f, 5f, 8f, 23f)
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, 0.0f)
+        val expected = sourceQuaternion // The source quaternion should be returned at ratio 0.
+
+        assertRotation(slerpResult, expected.x, expected.y, expected.z, expected.w)
+    }
+
+    @Test
+    fun sphericalInterpolate_RatioOne_returnsDestinationQuaternion() {
+        val axis = Vector3(1f, 2f, 3f)
+        val sourceQuaternion = Quaternion.fromAxisAngle(axis, 90f)
+        val destinationQuaternion = Quaternion.fromAxisAngle(axis, 180f)
+        val slerpResult = Quaternion.slerp(sourceQuaternion, destinationQuaternion, 1.0f)
+        val expected = destinationQuaternion // The destination quat should be returned at ratio 1.
+
+        assertRotation(slerpResult, expected.x, expected.y, expected.z, expected.w)
+    }
+
+    @Test
+    fun dot_returnsDotProductOfTwoQuaternions() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTest2 = Quaternion(1f, -2f, 3f, 2f)
+        val underTestDot = underTest.dot(underTest2)
+        val underTestDot2 = Quaternion.dot(underTest, underTest2)
+
+        assertThat(underTestDot).isEqualTo(0.6024641f)
+        assertThat(underTestDot2).isEqualTo(0.6024641f)
+    }
+
+    @Test
+    fun eulerAngles_returnedFromQuaternion() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val yawPitchRollVector = underTest.eulerAngles
+
+        assertThat(yawPitchRollVector.x).isWithin(1.0e-5f).of(-7.66226f)
+        assertThat(yawPitchRollVector.y).isWithin(1.0e-5f).of(47.726307f)
+        assertThat(yawPitchRollVector.z).isWithin(1.0e-5f).of(70.34616f)
+    }
+
+    @Test
+    fun rotationBetweenQuaternions_returnsAngleBetweenTwoQuaternions1() {
+        assertThat(Quaternion.angle(Quaternion(1f, 2f, 3f, 0f), Quaternion(1f, 2f, 3f, 0f)))
+            .isEqualTo(0.055952907f)
+    }
+
+    @Test
+    fun rotationBetweenQuaternions_returnsAngleBetweenTwoQuaternions2() {
+        assertThat(Quaternion.angle(Quaternion(1f, 2f, 3f, 5f), Quaternion(-1f, -2f, -3f, 5f)))
+            .isEqualTo(147.23466f)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoVectors1() {
+        val underTest = Quaternion.fromRotation(Vector3(1f, 0f, 0f), Vector3(1f, 0f, 0f))
+
+        assertRotation(underTest, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoVectors2() {
+        val underTest = Quaternion.fromRotation(Vector3(1f, 0f, 0f), Vector3(-1f, 0f, 0f))
+
+        assertRotation(underTest, 0f, 1f, 0f, 0f)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoVectors3() {
+        val underTest = Quaternion.fromRotation(Vector3(1f, 0f, 0f), Vector3(0f, 1f, 0f))
+
+        assertRotation(underTest, 0f, 0f, 0.7071f, 0.7071f)
+    }
+
+    @Test
+    fun axisAngle_returnsAxisAngleOfQuaternion1() {
+        assertAxisAngle(Quaternion(0f, 1f, 0f, 0f), Vector3(0f, 1f, 0f), 180f)
+    }
+
+    @Test
+    fun axisAngle_returnsAxisAngleOfQuaternion2() {
+        assertAxisAngle(Quaternion(0f, 0f, 0.7071f, 0.7071f), Vector3(0f, 0f, 1f), 90f)
+    }
+
+    @Test
+    fun axisAngle_returnsAxisAngleOfQuaternion3() {
+        assertAxisAngle(Quaternion(0f, 0f, 0f, 1f), Vector3(1f, 0f, 0f), 0f)
+    }
+
+    @Test
+    fun axisAngle_returnsAxisAngleOfQuaternion4() {
+        assertAxisAngle(Quaternion(0f, 0.7071f, 0f, 0.7071f), Vector3(0f, 1f, 0f), 90f)
+    }
+
+    private fun assertAxisAngle(rotation: Quaternion, axis: Vector3, angle: Float) {
+        assertThat(rotation.axisAngle.first.x).isWithin(1.0e-5f).of(axis.x)
+        assertThat(rotation.axisAngle.first.y).isWithin(1.0e-5f).of(axis.y)
+        assertThat(rotation.axisAngle.first.z).isWithin(1.0e-5f).of(axis.z)
+        assertThat(rotation.axisAngle.second).isWithin(1.0e-5f).of(angle)
+    }
+
+    @Test
+    fun copy_returnsCopyOfQuaternion() {
+        val underTest = Quaternion(1f, 2f, 3f, 4f)
+        val underTestCopy = underTest.copy()
+
+        assertRotation(underTestCopy, underTest.x, underTest.y, underTest.z, underTest.w)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoQuaternions1() {
+        val resultantQuaternion =
+            Quaternion.fromRotation(Quaternion(1f, 2f, 3f, 0f), Quaternion(1f, 2f, 3f, 0f))
+
+        assertRotation(resultantQuaternion, 0f, 0f, 0f, 1f)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoQuaternions2() {
+        val resultantQuaternion =
+            Quaternion.fromRotation(
+                Quaternion(0f, 0f, 0.7071f, 0.7071f),
+                Quaternion(0f, 0f, 0.7071f, -0.7071f),
+            )
+
+        assertRotation(resultantQuaternion, 0f, 0f, 1f, 0f)
+    }
+
+    @Test
+    fun fromRotation_returnsQuaternionFromRotationBetweenTwoQuaternions3() {
+        val resultantQuaternion =
+            Quaternion.fromRotation(
+                Quaternion(0.7071f, 0f, 0f, 0.7071f),
+                Quaternion(0f, 0.7071f, 0f, 0.7071f),
+            )
+
+        assertRotation(resultantQuaternion, -0.5f, 0.5f, 0.5f, 0.5f)
+    }
+
+    private fun assertRotation(
+        rotation: Quaternion,
+        expectedX: Float,
+        expectedY: Float,
+        expectedZ: Float,
+        expectedW: Float,
+        tolerance: Float = 1.0e-4f,
+    ) {
+        assertThat(rotation.x).isWithin(tolerance).of(expectedX)
+        assertThat(rotation.y).isWithin(tolerance).of(expectedY)
+        assertThat(rotation.z).isWithin(tolerance).of(expectedZ)
+        assertThat(rotation.w).isWithin(tolerance).of(expectedW)
+    }
+
+    @Test
+    fun fromLookTowards_returnsQuaternionFromForwardAndUpVectors() {
+        val resultantQuaternion =
+            Quaternion.fromLookTowards(Vector3(0f, 0f, 1f), Vector3(0f, 1f, 0f))
+
+        assertRotation(resultantQuaternion, 0f, 0f, 0f, 1f)
+
+        val resultantQuaternion2 =
+            Quaternion.fromLookTowards(Vector3(1f, 0f, 0f), Vector3(0f, 1f, 0f))
+
+        assertRotation(resultantQuaternion2, 0f, 0.707107f, 0f, 0.707107f)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/RayTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/RayTest.kt
new file mode 100644
index 0000000..018eb6d
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/RayTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class RayTest {
+    @Test
+    fun constructor_noArguments_returnsDefaultVector() {
+        val underTest = Ray()
+
+        assertThat(underTest.origin).isEqualTo(Vector3())
+        assertThat(underTest.direction).isEqualTo(Vector3())
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(Vector3(3f, 4f, 5f), Vector3(6f, 7f, 8f))
+        val underTest3 = Ray()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(Vector3(3f, 4f, 5f), Vector3(6f, 7f, 8f))
+        val underTest3 = Ray()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(Vector3(3f, 4f, 5f), Vector3(6f, 7f, 8f))
+
+        assertThat(underTest.toString())
+            .isEqualTo("[origin=[x=1.0, y=2.0, z=3.0], direction=[x=4.0, y=5.0, z=6.0]]")
+        assertThat(underTest2.toString())
+            .isEqualTo("[origin=[x=3.0, y=4.0, z=5.0], direction=[x=6.0, y=7.0, z=8.0]]")
+    }
+
+    @Test
+    fun constructor_fromRay_returnsSameValues() {
+        val underTest = Ray(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f))
+        val underTest2 = Ray(underTest)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector2Test.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector2Test.kt
new file mode 100644
index 0000000..629129c
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector2Test.kt
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class Vector2Test {
+    @Test
+    fun constructor_noArguments_returnsZeroVector() {
+        val underTest = Vector2()
+
+        assertThat(underTest.x).isEqualTo(0)
+        assertThat(underTest.y).isEqualTo(0)
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(1f, 2f)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(9f, 10f)
+        val underTest3 = Vector3()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(1f, 2f)
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(9f, 10f)
+        val underTest3 = Vector2()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2()
+
+        assertThat(underTest.toString()).isEqualTo("[x=1.0, y=2.0]")
+        assertThat(underTest2.toString()).isEqualTo("[x=0.0, y=0.0]")
+    }
+
+    @Test
+    fun constructor_fromVector2_returnsSameValues() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = underTest
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun normalized_returnsVectorWithUnitLength() {
+        assertThat(Vector2(3f, 4f).toNormalized()).isEqualTo(Vector2(0.6f, 0.8f))
+        assertThat(Vector2(1f, 1f).toNormalized()).isEqualTo(Vector2(0.70710677f, 0.70710677f))
+    }
+
+    @Test
+    fun multiply_returnsVectorScaledByScalar() {
+        assertThat(Vector2(3f, 4f) * 2f).isEqualTo(Vector2(6f, 8f))
+        assertThat(Vector2(1f, 1f) * 0.5f).isEqualTo(Vector2(0.5f, 0.5f))
+    }
+
+    @Test
+    fun add_returnsTwoVectorsAddedTogether() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(3f, 4f)
+        val underTestAdd = underTest + underTest2
+
+        assertThat(underTestAdd).isEqualTo(Vector2(4f, 6f))
+    }
+
+    @Test
+    fun subtract_returnsTwoVectorsSubtracted() {
+        val underTest = Vector2(1f, 5f)
+        val underTest2 = Vector2(3f, 4f)
+        val underTestSubtract = underTest - underTest2
+
+        assertThat(underTestSubtract).isEqualTo(Vector2(-2f, 1f))
+    }
+
+    @Test
+    fun multiply_returnsTwoVectorsMultiplied() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(3f, 4f)
+        val underTestMultiply = underTest * underTest2
+
+        assertThat(underTestMultiply).isEqualTo(Vector2(3f, 8f))
+    }
+
+    @Test
+    fun cross_returnsCrossProductOfTwoVectors() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(3f, 4f)
+        val underTestCross = underTest cross underTest2
+
+        assertThat(underTestCross).isEqualTo(-2f)
+    }
+
+    @Test
+    fun dot_returnsDotProductOfTwoVectors() {
+        assertThat(Vector2(1f, 2f) dot Vector2(3f, 4f)).isEqualTo(11f)
+        assertThat(Vector2(-1f, 2f) dot Vector2(1f, 10f)).isEqualTo(19f)
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors1() {
+        val underTest = Vector2(3f, 4f).clamp(Vector2(5f, 6f), Vector2(7f, 8f))
+
+        assertThat(underTest).isEqualTo(Vector2(5f, 6f))
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors2() {
+        val underTest = Vector2(3f, 4f).clamp(Vector2(1f, 2f), Vector2(5f, 6f))
+
+        assertThat(underTest).isEqualTo(Vector2(3f, 4f))
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors3() {
+        val underTest = Vector2(5f, 6f).clamp(Vector2(1f, 2f), Vector2(3f, 4f))
+
+        assertThat(underTest).isEqualTo(Vector2(3f, 4f))
+    }
+
+    @Test
+    fun unaryMinus_returnsVectorWithNegativeValues() {
+        val underTest = Vector2(1f, 2f)
+        val underTestNegative = -underTest
+
+        assertThat(underTestNegative).isEqualTo(Vector2(-1f, -2f))
+    }
+
+    @Test
+    fun div_returnsVectorDividedByScalar() {
+        val underTest = Vector2(1f, -2f)
+        val underTestDiv = underTest / -2f
+
+        assertThat(underTestDiv).isEqualTo(Vector2(-0.5f, 1f))
+    }
+
+    @Test
+    fun divide_returnsVectorDividedByVector() {
+        val underTest = Vector2(1f, 2f)
+        val underTest2 = Vector2(-2f, 4f)
+        val underTestDiv = underTest / underTest2
+
+        assertThat(underTestDiv).isEqualTo(Vector2(-0.5f, 0.5f))
+    }
+
+    @Test
+    fun distance_returnsDistanceBetweenTwoVectors() {
+        val underTest = Vector2(1f, 0f)
+        val underTest2 = Vector2(2f, 2f)
+        val underTestDistance = Vector2.distance(underTest, underTest2)
+
+        assertThat(underTestDistance).isEqualTo(2.2360679775f) // sqrt(1^2 + 2^2)
+    }
+
+    @Test
+    fun copy_returnsCopyOfVector() {
+        val underTest = Vector2(1f, 2f)
+        val underTestCopy = underTest.copy()
+
+        assertThat(underTestCopy).isEqualTo(underTest)
+    }
+
+    @Test
+    fun angularDistance_returnsAngleBetweenTwoVectors1() {
+        assertThat(Vector2.angularDistance(Vector2(1f, 0f), Vector2(0f, 1f)))
+            .isWithin(1e-5f)
+            .of(90f)
+    }
+
+    @Test
+    fun angularDistance_returnsAngleBetweenTwoVectors2() {
+        assertThat(Vector2.angularDistance(Vector2(1f, 0f), Vector2(-1f, 0f)))
+            .isWithin(1e-5f)
+            .of(180f)
+    }
+
+    @Test
+    fun angularDistance_returnsAngleBetweenTwoVectors3() {
+        assertThat(Vector2.angularDistance(Vector2(2f, 4f), Vector2(4f, 8f))).isWithin(1e-5f).of(0f)
+    }
+
+    @Test
+    fun angularDistance_returnsAngleBetweenTwoVectors4() {
+        assertThat(Vector2.angularDistance(Vector2(2f, 2f), Vector2(0f, 3f)))
+            .isWithin(1e-5f)
+            .of(45f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector1() {
+        val underTest = Vector2.lerp(Vector2(1f, 2f), Vector2(2f, 4f), 0.5f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(1.5f)
+        assertThat(underTest.y).isWithin(1e-5f).of(3f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector2() {
+        val underTest = Vector2.lerp(Vector2(4f, 5f), Vector2(12f, 15f), 0.25f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(6f)
+        assertThat(underTest.y).isWithin(1e-5f).of(7.5f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector3() {
+        val underTest = Vector2.lerp(Vector2(2f, 6f), Vector2(12f, 26f), 0.4f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(6f)
+        assertThat(underTest.y).isWithin(1e-5f).of(14f)
+    }
+
+    @Test
+    fun abs_returnsAbsoluteValueofVector2() {
+        val underTest = Vector2.abs(Vector2(-3f, 4f))
+
+        assertThat(underTest.x).isEqualTo(3f)
+        assertThat(underTest.y).isEqualTo(4f)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector3Test.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector3Test.kt
new file mode 100644
index 0000000..925d731
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector3Test.kt
@@ -0,0 +1,310 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class Vector3Test {
+    @Test
+    fun constructor_noArguments_returnsZeroVector() {
+        val underTest = Vector3()
+
+        assertThat(underTest).isEqualTo(Vector3(0f, 0f, 0f))
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(1f, 2f, 3f)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(9f, 10f, 11f)
+        val underTest3 = Vector2()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(1f, 2f, 3f)
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(9f, 10f, 11f)
+        val underTest3 = Vector3()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3()
+
+        assertThat(underTest.toString()).isEqualTo("[x=1.0, y=2.0, z=3.0]")
+        assertThat(underTest2.toString()).isEqualTo("[x=0.0, y=0.0, z=0.0]")
+    }
+
+    @Test
+    fun constructor_fromVector3_returnsSameValues() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = underTest
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun fromFloat_returnsSameValues() {
+        val underTest = Vector3.fromValue(1f)
+
+        assertThat(underTest).isEqualTo(Vector3.One)
+    }
+
+    @Test
+    fun normalized_returnsVectorWithUnitLength() {
+        assertThat(Vector3(3f, 4f, 5f).toNormalized())
+            .isEqualTo(Vector3(0.42426407f, 0.56568545f, 0.7071068f))
+
+        assertThat(Vector3(1f, 1f, 0.5f).toNormalized())
+            .isEqualTo(Vector3(0.6666667f, 0.6666667f, 0.33333334f))
+    }
+
+    @Test
+    fun multiply_returnsVectorScaledByScalar() {
+        assertThat(Vector3(3f, 4f, 5f) * 2f).isEqualTo(Vector3(6f, 8f, 10f))
+
+        assertThat(Vector3(1f, 1f, 0.5f) * 0.5f).isEqualTo(Vector3(0.5f, 0.5f, 0.25f))
+    }
+
+    @Test
+    fun plus_returnsVectorWithAddedValues() {
+        val underTest = Vector3(1F, 2F, 3F) + Vector3(4F, 5F, 6F)
+
+        assertThat(underTest.x).isEqualTo(5F) // 1 + 4
+        assertThat(underTest.y).isEqualTo(7F) // 2 + 5
+        assertThat(underTest.z).isEqualTo(9F) // 3 + 6
+    }
+
+    @Test
+    fun minus_returnsVectorWithSubtractedValues() {
+        val underTest = Vector3(4F, 5F, 6F) - Vector3(1F, 2F, 3F)
+
+        assertThat(underTest.x).isEqualTo(3F) // 4 - 1
+        assertThat(underTest.y).isEqualTo(3F) // 5 - 2
+        assertThat(underTest.z).isEqualTo(3F) // 6 - 3
+    }
+
+    @Test
+    fun multiply_returnsTwoVectorsMultiplied() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(3f, 4f, 5f)
+        val underTestMultiply = underTest * underTest2
+
+        assertThat(underTestMultiply).isEqualTo(Vector3(3f, 8f, 15f))
+    }
+
+    @Test
+    fun dot_returnsDotProductOfTwoVectors() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(3f, -4f, 5f)
+        val underTestDot = underTest dot underTest2
+
+        assertThat(underTestDot).isEqualTo(10f)
+    }
+
+    @Test
+    fun cross_returnsCrossProductOfTwoVectors() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(3f, -4f, 5f)
+        val underTestCross = underTest cross underTest2
+
+        assertThat(underTestCross).isEqualTo(Vector3(22f, 4f, -10f))
+    }
+
+    @Test
+    fun length_returnsSqrtOfEachComponentSquared() {
+        assertThat(Vector3(0F, 3F, 4F).length).isEqualTo(5F) // sqrt(0^2 + 3^2 + 4^2) = sqrt(25)
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors1() {
+        val underTest = Vector3(1f, 2f, 3f).clamp(Vector3(4f, 5f, 6f), Vector3(7f, 8f, 9f))
+
+        assertThat(underTest).isEqualTo(Vector3(4f, 5f, 6f))
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors2() {
+        val underTest = Vector3(1f, 2f, 3f).clamp(Vector3(1f, 2f, 3f), Vector3(5f, 6f, 7f))
+
+        assertThat(underTest).isEqualTo(Vector3(1f, 2f, 3f))
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors3() {
+        val underTest = Vector3(5f, 6f, 7f).clamp(Vector3(1f, 2f, 3f), Vector3(5f, 6f, 7f))
+
+        assertThat(underTest).isEqualTo(Vector3(5f, 6f, 7f))
+    }
+
+    @Test
+    fun angleBetweenVectors_returnsAngleBetweenTwoVectors1() {
+        assertThat(toDegrees(Vector3.angleBetween(Vector3(1f, 0f, 0f), Vector3(0f, 1f, 0f))))
+            .isWithin(1e-5f)
+            .of(90f)
+    }
+
+    @Test
+    fun angleBetweenVectors_returnsAngleBetweenTwoVectors2() {
+        assertThat(toDegrees(Vector3.angleBetween(Vector3(0f, 0f, 1f), Vector3(0f, 0f, -1f))))
+            .isWithin(1e-5f)
+            .of(180f)
+    }
+
+    @Test
+    fun angleBetweenVectors_returnsAngleBetweenTwoVectors3() {
+        assertThat(toDegrees(Vector3.angleBetween(Vector3(2f, 4f, 0f), Vector3(4f, 8f, 0f))))
+            .isWithin(1e-5f)
+            .of(0f)
+    }
+
+    @Test
+    fun angleBetweenVectors_returnsAngleBetweenTwoVectors4() {
+        assertThat(toDegrees(Vector3.angleBetween(Vector3(2f, 2f, 0f), Vector3(0f, 3f, 0f))))
+            .isWithin(1e-5f)
+            .of(45f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector1() {
+        val underTest = Vector3.lerp(Vector3(1f, 2f, 3f), Vector3(4f, 5f, 6f), 0.5f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(2.5f)
+        assertThat(underTest.y).isWithin(1e-5f).of(3.5f)
+        assertThat(underTest.z).isWithin(1e-5f).of(4.5f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector2() {
+        val underTest = Vector3.lerp(Vector3(4f, 5f, 6f), Vector3(12f, 15f, 18f), 0.25f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(6f)
+        assertThat(underTest.y).isWithin(1e-5f).of(7.5f)
+        assertThat(underTest.z).isWithin(1e-5f).of(9f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector3() {
+        val underTest = Vector3.lerp(Vector3(2f, 6f, 10f), Vector3(12f, 26f, 30f), 0.4f)
+
+        assertThat(underTest.x).isWithin(1e-5f).of(6f)
+        assertThat(underTest.y).isWithin(1e-5f).of(14f)
+        assertThat(underTest.z).isWithin(1e-5f).of(18f)
+    }
+
+    @Test
+    fun unaryMinus_returnsVectorWithNegativeValues() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTestNegative = -underTest
+
+        assertThat(underTestNegative).isEqualTo(Vector3(-1f, -2f, -3f))
+    }
+
+    @Test
+    fun div_returnsVectorDividedByScalar() {
+        val underTest = Vector3(1f, -2f, 3f)
+        val underTestDiv = underTest / -2f
+
+        assertThat(underTestDiv).isEqualTo(Vector3(-0.5f, 1f, -1.5f))
+    }
+
+    @Test
+    fun divide_returnsVectorDividedByVector() {
+        val underTest = Vector3(1f, 2f, 6f)
+        val underTest2 = Vector3(-2f, 4f, -3f)
+        val underTestDiv = underTest / underTest2
+
+        assertThat(underTestDiv).isEqualTo(Vector3(-0.5f, 0.5f, -2f))
+    }
+
+    @Test
+    fun projectOnPlane_returnsVectorProjectedOnPlane() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTest2 = Vector3(1f, 2f, 0f)
+        val underTestProject = Vector3.projectOnPlane(underTest, underTest2)
+
+        assertThat(underTestProject).isEqualTo(Vector3(0f, 0f, 3f))
+    }
+
+    @Test
+    fun distance_returnsDistanceBetweenTwoVectors() {
+        val underTest = Vector3(1f, 0f, 1f)
+        val underTest2 = Vector3(2f, 2f, 5f)
+        val underTestDistance = Vector3.distance(underTest, underTest2)
+
+        assertThat(underTestDistance).isWithin(1.0e-4f).of(4.58257569f) // sqrt(1^2 + 2^2 + 4^2)
+    }
+
+    @Test
+    fun abs_returnsVectorWithAbsoluteValues() {
+        val underTest = Vector3.abs(Vector3(-1f, 2f, -3f))
+
+        assertThat(underTest).isEqualTo(Vector3(1f, 2f, 3f))
+    }
+
+    @Test
+    fun maxVector_returnsMaxVectorFromTwoVectors() {
+        val underTest = Vector3(8f, 2f, -3f)
+        val underTest2 = Vector3(4f, 5f, 6f)
+        val underTestMax = Vector3.max(underTest, underTest2)
+
+        assertThat(underTestMax).isEqualTo(Vector3(8f, 5f, 6f))
+    }
+
+    @Test
+    fun minVector_returnsMinVectorFromTwoVectors() {
+        val underTest = Vector3(8f, 2f, -3f)
+        val underTest2 = Vector3(4f, 5f, 6f)
+        val underTestMin = Vector3.min(underTest, underTest2)
+
+        assertThat(underTestMin).isEqualTo(Vector3(4f, 2f, -3f))
+    }
+
+    @Test
+    fun copy_returnsCopyOfVector() {
+        val underTest = Vector3(1f, 2f, 3f)
+        val underTestCopy = underTest.copy()
+
+        assertThat(underTestCopy).isEqualTo(underTest)
+    }
+}
diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector4Test.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector4Test.kt
new file mode 100644
index 0000000..c2af8af
--- /dev/null
+++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/math/Vector4Test.kt
@@ -0,0 +1,315 @@
+/*
+ * Copyright 2024 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 androidx.xr.runtime.math
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class Vector4Test {
+    @Test
+    fun constructor_noArguments_returnsZeroVector() {
+        val underTest = Vector4()
+
+        assertThat(underTest).isEqualTo(Vector4(0f, 0f, 0f, 0f))
+    }
+
+    @Test
+    fun equals_sameValues_returnsTrue() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(1f, 2f, 3f, 4f)
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun equals_differentValues_returnsFalse() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(9f, 10f, 11f, 12f)
+        val underTest3 = Vector3()
+
+        assertThat(underTest).isNotEqualTo(underTest2)
+        assertThat(underTest).isNotEqualTo(underTest3)
+    }
+
+    @Test
+    fun hashCodeEquals_sameValues_returnsTrue() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(1f, 2f, 3f, 4f)
+
+        assertThat(underTest.hashCode()).isEqualTo(underTest2.hashCode())
+    }
+
+    @Test
+    fun hashCodeEquals_differentValues_returnsFalse() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(9f, 10f, 11f, 12f)
+        val underTest3 = Vector4()
+
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest2.hashCode())
+        assertThat(underTest.hashCode()).isNotEqualTo(underTest3.hashCode())
+    }
+
+    @Test
+    fun constructorEquals_expectedToString_returnsTrue() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4()
+
+        assertThat(underTest.toString()).isEqualTo("[x=1.0, y=2.0, z=3.0, w=4.0]")
+        assertThat(underTest2.toString()).isEqualTo("[x=0.0, y=0.0, z=0.0, w=0.0]")
+    }
+
+    @Test
+    fun constructor_fromVector4_returnsSameValues() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = underTest
+
+        assertThat(underTest).isEqualTo(underTest2)
+    }
+
+    @Test
+    fun fromFloat_returnsSameValues() {
+        val underTest = Vector4.fromValue(1f)
+
+        assertThat(underTest).isEqualTo(Vector4.One)
+    }
+
+    @Test
+    fun normalized_returnsVectorWithUnitLength1() {
+        val underTest = Vector4(1f, 1f, 2f, 2f).toNormalized()
+
+        assertVector4(underTest, 0.3162f, 0.3162f, 0.6325f, 0.6325f)
+    }
+
+    @Test
+    fun normalized_returnsVectorWithUnitLength2() {
+        val underTest = Vector4(1f, 1f, 1f, 1f).toNormalized()
+
+        assertVector4(underTest, 0.5f, 0.5f, 0.5f, 0.5f)
+    }
+
+    @Test
+    fun multiply_returnsVectorScaledByScalar1() {
+        assertThat(Vector4(3f, 4f, 5f, 6f) * 2f).isEqualTo(Vector4(6f, 8f, 10f, 12f))
+    }
+
+    @Test
+    fun multiply_returnsVectorScaledByScalar2() {
+        assertThat(Vector4(1f, 1f, 0.5f, 10f) * 0.5f).isEqualTo(Vector4(0.5f, 0.5f, 0.25f, 5f))
+    }
+
+    @Test
+    fun plus_returnsVectorWithAddedValues() {
+        val underTest = Vector4(1F, 2F, 3F, 4F) + Vector4(4F, 5F, 6F, 7F)
+
+        assertVector4(underTest, 5F, 7F, 9F, 11F)
+    }
+
+    @Test
+    fun minus_returnsVectorWithSubtractedValues() {
+        val underTest = Vector4(4F, 5F, 6F, 7F) - Vector4(1F, 2F, 3F, 4F)
+
+        assertVector4(underTest, 3F, 3F, 3F, 3F)
+    }
+
+    @Test
+    fun multiply_returnsTwoVectorsMultiplied() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(3f, 4f, 5f, 6f)
+        val underTestMultiply = underTest * underTest2
+
+        assertThat(underTestMultiply).isEqualTo(Vector4(3f, 8f, 15f, 24f))
+    }
+
+    @Test
+    fun dot_returnsDotProductOfTwoVectors() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTest2 = Vector4(3f, -4f, 5f, 2f)
+        val underTestDot = underTest dot underTest2
+
+        assertThat(underTestDot).isEqualTo(18f)
+    }
+
+    @Test
+    fun length_returnsSqrtOfEachComponentSquared() {
+        assertThat(Vector4(0F, 0F, 3F, 4F).length)
+            .isEqualTo(5F) // sqrt(0^2 + 0^2 + 3^2 + 4^2) = sqrt(25)
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors1() {
+        val underTest =
+            Vector4(1f, 2f, 3f, 4f).clamp(Vector4(4f, 5f, 6f, 7f), Vector4(7f, 8f, 9f, 10f))
+
+        assertVector4(underTest, 4f, 5f, 6f, 7f)
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors2() {
+        val underTest =
+            Vector4(1f, 2f, 3f, 4f).clamp(Vector4(1f, 2f, 3f, 4f), Vector4(5f, 6f, 7f, 8f))
+
+        assertVector4(underTest, 1f, 2f, 3f, 4f)
+    }
+
+    @Test
+    fun clamp_returnsVectorClampedBetweenTwoVectors3() {
+        val underTest =
+            Vector4(6f, 7f, 8f, 9f).clamp(Vector4(1f, 2f, 3f, 4f), Vector4(5f, 6f, 7f, 8f))
+
+        assertVector4(underTest, 5f, 6f, 7f, 8f)
+    }
+
+    @Test
+    fun unaryMinus_returnsVectorWithNegativeValues() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTestNegative = -underTest
+
+        assertThat(underTestNegative).isEqualTo(Vector4(-1f, -2f, -3f, -4f))
+    }
+
+    @Test
+    fun div_returnsVectorDividedByScalar() {
+        val underTest = Vector4(1f, -2f, 3f, -4f)
+        val underTestDiv = underTest / -2f
+
+        assertThat(underTestDiv).isEqualTo(Vector4(-0.5f, 1f, -1.5f, 2f))
+    }
+
+    @Test
+    fun divide_returnsVectorDividedByVector() {
+        val underTest = Vector4(1f, 2f, 6f, 12f)
+        val underTest2 = Vector4(-2f, 4f, -3f, 2f)
+        val underTestDiv = underTest / underTest2
+
+        assertThat(underTestDiv).isEqualTo(Vector4(-0.5f, 0.5f, -2f, 6f))
+    }
+
+    @Test
+    fun abs_returnsVectorWithAbsoluteValues() {
+        val underTest = Vector4.abs(Vector4(-1f, 2f, -3f, 4f))
+
+        assertThat(underTest).isEqualTo(Vector4(1f, 2f, 3f, 4f))
+    }
+
+    @Test
+    fun copy_returnsCopyOfVector() {
+        val underTest = Vector4(1f, 2f, 3f, 4f)
+        val underTestCopy = underTest.copy()
+
+        assertThat(underTestCopy).isEqualTo(underTest)
+    }
+
+    @Test
+    fun angleBetween_returnsAngleBetweenTwoVectors1() {
+        assertThat(
+                toDegrees(Vector4.angleBetween(Vector4(1f, 0f, 0f, 0f), Vector4(0f, 1f, 0f, 0f)))
+            )
+            .isWithin(1e-5f)
+            .of(90f)
+    }
+
+    @Test
+    fun angleBetween_returnsAngleBetweenTwoVectors2() {
+        assertThat(
+                toDegrees(Vector4.angleBetween(Vector4(0f, 0f, 1f, 0f), Vector4(0f, 0f, -1f, 0f)))
+            )
+            .isWithin(1e-5f)
+            .of(180f)
+    }
+
+    @Test
+    fun angleBetween_returnsAngleBetweenTwoVectors3() {
+        assertThat(
+                toDegrees(Vector4.angleBetween(Vector4(2f, 4f, 0f, 0f), Vector4(4f, 8f, 0f, 0f)))
+            )
+            .isWithin(1e-5f)
+            .of(0f)
+    }
+
+    @Test
+    fun angleBetween_returnsAngleBetweenTwoVectors4() {
+        assertThat(
+                toDegrees(Vector4.angleBetween(Vector4(0f, 0f, 0f, 1f), Vector4(0f, 0f, 0f, 0f)))
+            )
+            .isWithin(1e-5f)
+            .of(0f)
+    }
+
+    @Test
+    fun distance_returnsDistanceBetweenTwoVectors() {
+        val underTest = Vector4(1f, 0f, 1f, 1f)
+        val underTest2 = Vector4(2f, 2f, 5f, 3f)
+        val underTestDistance = Vector4.distance(underTest, underTest2)
+
+        assertThat(underTestDistance).isWithin(1.0e-4f).of(5f) // sqrt(1^2 + 2^2 + 4^2 + 2^2)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector1() {
+        val underTest = Vector4.lerp(Vector4(1f, 2f, 3f, 4f), Vector4(4f, 5f, 6f, 7f), 0.5f)
+
+        assertVector4(underTest, 2.5f, 3.5f, 4.5f, 5.5f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector2() {
+        val underTest = Vector4.lerp(Vector4(4f, 5f, 6f, 7f), Vector4(12f, 15f, 18f, 21f), 0.25f)
+
+        assertVector4(underTest, 6f, 7.5f, 9f, 10.5f)
+    }
+
+    @Test
+    fun lerp_returnsInterpolatedVector3() {
+        val underTest = Vector4.lerp(Vector4(2f, 6f, 10f, 14f), Vector4(12f, 26f, 30f, 34f), 0.4f)
+
+        assertVector4(underTest, 6f, 14f, 18f, 22f)
+    }
+
+    @Test
+    fun maxVector_returnsMaxVectorFromTwoVectors() {
+        val underTest = Vector4(8f, 2f, -3f, 5f)
+        val underTest2 = Vector4(4f, 5f, 6f, 2f)
+        val underTestMax = Vector4.max(underTest, underTest2)
+
+        assertThat(underTestMax).isEqualTo(Vector4(8f, 5f, 6f, 5f))
+    }
+
+    @Test
+    fun minVector_returnsMinVectorFromTwoVectors() {
+        val underTest = Vector4(8f, 2f, -3f, 0f)
+        val underTest2 = Vector4(4f, 5f, 6f, -1f)
+        val underTestMin = Vector4.min(underTest, underTest2)
+
+        assertThat(underTestMin).isEqualTo(Vector4(4f, 2f, -3f, -1f))
+    }
+
+    private fun assertVector4(
+        vector: Vector4,
+        expectedX: Float,
+        expectedY: Float,
+        expectedZ: Float,
+        expectedW: Float,
+    ) {
+        assertThat(vector.x).isWithin(1.0e-4f).of(expectedX)
+        assertThat(vector.y).isWithin(1.0e-4f).of(expectedY)
+        assertThat(vector.z).isWithin(1.0e-4f).of(expectedZ)
+        assertThat(vector.w).isWithin(1.0e-4f).of(expectedW)
+    }
+}
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/scenecore/scenecore-testing/api/current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/current.txt
copy to xr/scenecore/scenecore-testing/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/scenecore/scenecore-testing/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/scenecore/scenecore-testing/api/res-current.txt
diff --git a/xr/scenecore/scenecore-testing/api/restricted_current.txt b/xr/scenecore/scenecore-testing/api/restricted_current.txt
new file mode 100644
index 0000000..9af083e
--- /dev/null
+++ b/xr/scenecore/scenecore-testing/api/restricted_current.txt
@@ -0,0 +1,318 @@
+// Signature format: 4.0
+package androidx.xr.scenecore.testing {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class FakeImpressApi implements com.google.ar.imp.apibindings.ImpressApi {
+    ctor public FakeImpressApi();
+    method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void!> animateGltfModel(int, String?, boolean);
+    method public int createImpressNode();
+    method public int createStereoSurface(@com.google.ar.imp.apibindings.ImpressApi.StereoMode int);
+    method public void destroyImpressNode(int);
+    method public java.util.List<java.lang.Integer!> getImpressNodesForToken(long);
+    method public android.view.Surface getSurfaceFromStereoSurface(int);
+    method public int impressNodeAnimatingSize();
+    method public boolean impressNodeHasParent(int);
+    method public int impressNodeLoopAnimatingSize();
+    method public int instanceGltfModel(long);
+    method public int instanceGltfModel(long, boolean);
+    method public com.google.common.util.concurrent.ListenableFuture<java.lang.Long!> loadGltfModel(String);
+    method public void onPause();
+    method public void onResume();
+    method public void releaseGltfModel(long);
+    method public void setCanvasDimensionsForStereoSurface(int, float, float);
+    method public void setGltfModelColliderEnabled(int, boolean);
+    method public void setImpressNodeParent(int, int);
+    method public void setStereoModeForStereoSurface(int, @com.google.ar.imp.apibindings.ImpressApi.StereoMode int);
+    method public void setup(com.google.ar.imp.view.View);
+    method public void stopGltfModelAnimation(int);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class FakeScheduledExecutorService extends java.util.concurrent.AbstractExecutorService implements java.lang.AutoCloseable java.util.concurrent.ScheduledExecutorService {
+    ctor public FakeScheduledExecutorService();
+    method public boolean awaitTermination(long, java.util.concurrent.TimeUnit?);
+    method public void close();
+    method public void execute(Runnable?);
+    method public boolean hasNext();
+    method @com.google.errorprone.annotations.CheckReturnValue public boolean isEmpty();
+    method public boolean isShutdown();
+    method public boolean isTerminated();
+    method public void runAll();
+    method public void runNext();
+    method public java.util.concurrent.ScheduledFuture<? extends java.lang.Object!> schedule(Runnable?, long, java.util.concurrent.TimeUnit?);
+    method public <V> java.util.concurrent.ScheduledFuture<V!> schedule(java.util.concurrent.Callable<V!>?, long, java.util.concurrent.TimeUnit?);
+    method public java.util.concurrent.ScheduledFuture<? extends java.lang.Object!> scheduleAtFixedRate(Runnable?, long, long, java.util.concurrent.TimeUnit?);
+    method public java.util.concurrent.ScheduledFuture<? extends java.lang.Object!> scheduleWithFixedDelay(Runnable?, long, long, java.util.concurrent.TimeUnit?);
+    method public void shutdown();
+    method public java.util.List<java.lang.Runnable!> shutdownNow();
+    method public void simulateSleepExecutingAllTasks(java.time.Duration);
+    method public boolean simulateSleepExecutingAtMostOneTask();
+    method public boolean simulateSleepExecutingAtMostOneTask(java.time.Duration);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class FakeXrExtensions implements androidx.xr.extensions.XrExtensions {
+    ctor public FakeXrExtensions();
+    method public void addFindableView(android.view.View, android.view.ViewGroup);
+    method @Deprecated public void attachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.node.Node);
+    method public void attachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void attachSpatialScene(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node);
+    method public void attachSpatialScene(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public boolean canEmbedActivityPanel(android.app.Activity);
+    method public void clearSpatialStateCallback(android.app.Activity);
+    method public androidx.xr.extensions.space.ActivityPanel createActivityPanel(android.app.Activity, androidx.xr.extensions.space.ActivityPanelLaunchParameters);
+    method public androidx.xr.extensions.node.Node createNode();
+    method public androidx.xr.extensions.node.NodeTransaction createNodeTransaction();
+    method public androidx.xr.extensions.node.ReformOptions createReformOptions(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.splitengine.SplitEngineBridge createSplitEngineBridge();
+    method public androidx.xr.extensions.subspace.Subspace createSubspace(androidx.xr.extensions.splitengine.SplitEngineBridge, int);
+    method @Deprecated public void detachSpatialEnvironment(android.app.Activity);
+    method public void detachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void detachSpatialScene(android.app.Activity);
+    method public void detachSpatialScene(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.XrExtensions.SceneViewerResult!> displayGltfModel(android.app.Activity!, androidx.xr.extensions.asset.GltfModelToken!);
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.FakeActivityPanel getActivityPanelForHost(android.app.Activity);
+    method public int getApiVersion();
+    method public void getBounds(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.Bounds!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.Config getConfig();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode? getFakeEnvironmentNode();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode? getFakeNodeForMainWindow();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode? getFakeTaskNode();
+    method public int getMainWindowHeight();
+    method public int getMainWindowWidth();
+    method public int getOpenXrWorldSpaceType();
+    method public float getPreferredAspectRatio();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.SpaceMode getSpaceMode();
+    method public void getSpatialCapabilities(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialCapabilities!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.space.SpatialState getSpatialState(android.app.Activity);
+    method public androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialState!>? getSpatialStateCallback();
+    method public androidx.xr.extensions.node.Node? getSurfaceTrackingNode(android.view.View);
+    method public androidx.xr.extensions.media.XrSpatialAudioExtensions getXrSpatialAudioExtensions();
+    method public void hitTest(android.app.Activity, androidx.xr.extensions.node.Vec3, androidx.xr.extensions.node.Vec3, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.HitTestResult!>, java.util.concurrent.Executor);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.EnvironmentToken!> loadEnvironment(java.io.InputStream?, int, int, String?);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.EnvironmentToken!> loadEnvironment(java.io.InputStream!, int, int, String!, int, int);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.GltfModelToken!> loadGltfModel(java.io.InputStream?, int, int, String?);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.SceneToken!> loadImpressScene(java.io.InputStream!, int, int);
+    method public void registerSpatialStateCallback(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialState!>, java.util.concurrent.Executor);
+    method public void removeFindableView(android.view.View, android.view.ViewGroup);
+    method @Deprecated public boolean requestFullSpaceMode(android.app.Activity);
+    method public void requestFullSpaceMode(android.app.Activity, boolean, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public boolean requestHomeSpaceMode(android.app.Activity);
+    method public void sendSpatialState(androidx.xr.extensions.space.SpatialState);
+    method public android.os.Bundle setFullSpaceMode(android.os.Bundle);
+    method public android.os.Bundle setFullSpaceModeWithEnvironmentInherited(android.os.Bundle);
+    method @Deprecated public android.os.Bundle setMainPanelCurvatureRadius(android.os.Bundle, float);
+    method @Deprecated public void setMainWindowCurvatureRadius(android.app.Activity, float);
+    method @Deprecated public void setMainWindowSize(android.app.Activity, int, int);
+    method public void setMainWindowSize(android.app.Activity, int, int, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method public void setOpenXrWorldSpaceType(int);
+    method public void setPreferredAspectRatio(android.app.Activity, float);
+    method public void setPreferredAspectRatio(android.app.Activity, float, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void setSpatialStateCallback(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialStateEvent!>, java.util.concurrent.Executor);
+    method @Deprecated public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode testGetNodeWithEnvironmentToken(androidx.xr.extensions.asset.EnvironmentToken);
+    method @Deprecated public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode testGetNodeWithGltfToken(androidx.xr.extensions.asset.GltfModelToken);
+    field public final java.util.Map<android.app.Activity!,androidx.xr.scenecore.testing.FakeXrExtensions.FakeActivityPanel!> activityPanelMap;
+    field public final java.util.List<androidx.xr.scenecore.testing.FakeXrExtensions.FakeEnvironmentToken!> createdEnvironmentTokens;
+    field public final java.util.List<androidx.xr.scenecore.testing.FakeXrExtensions.FakeGltfModelToken!> createdGltfModelTokens;
+    field public final java.util.List<androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode!> createdNodes;
+    field public final androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialAudioExtensions fakeSpatialAudioExtensions;
+    field public final androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialState fakeSpatialState;
+  }
+
+  public static class FakeXrExtensions.FakeActivityPanel implements androidx.xr.extensions.space.ActivityPanel {
+    method public void delete();
+    method public android.app.Activity? getActivity();
+    method public android.graphics.Rect? getBounds();
+    method public android.os.Bundle getBundle();
+    method public android.content.Intent? getLaunchIntent();
+    method public androidx.xr.extensions.node.Node getNode();
+    method public boolean isDeleted();
+    method public void launchActivity(android.content.Intent, android.os.Bundle?);
+    method public void moveActivity(android.app.Activity);
+    method public void setWindowBounds(android.graphics.Rect);
+  }
+
+  public static class FakeXrExtensions.FakeAudioTrackExtensions implements androidx.xr.extensions.media.AudioTrackExtensions {
+    ctor public FakeXrExtensions.FakeAudioTrackExtensions();
+    method public androidx.xr.extensions.media.PointSourceAttributes? getPointSourceAttributes();
+    method public androidx.xr.extensions.media.SoundFieldAttributes? getSoundFieldAttributes();
+    method public void setPointSourceAttributes(androidx.xr.extensions.media.PointSourceAttributes?);
+    method public void setSoundFieldAttributes(androidx.xr.extensions.media.SoundFieldAttributes?);
+    method public void setSourceType(@androidx.xr.extensions.media.SpatializerExtensions.SourceType int);
+  }
+
+  public static class FakeXrExtensions.FakeCloseable implements java.io.Closeable {
+    ctor public FakeXrExtensions.FakeCloseable();
+    method public void close();
+    method public boolean isClosed();
+  }
+
+  public static class FakeXrExtensions.FakeConfig implements androidx.xr.extensions.Config {
+    ctor public FakeXrExtensions.FakeConfig();
+    method public float defaultPixelsPerMeter(float);
+    field public static final float DEFAULT_PIXELS_PER_METER = 1.0f;
+  }
+
+  public static class FakeXrExtensions.FakeEnvironmentToken implements androidx.xr.extensions.asset.EnvironmentToken {
+    method public String getUrl();
+  }
+
+  public static class FakeXrExtensions.FakeEnvironmentVisibilityState implements androidx.xr.extensions.environment.EnvironmentVisibilityState {
+    ctor public FakeXrExtensions.FakeEnvironmentVisibilityState(@androidx.xr.extensions.environment.EnvironmentVisibilityState.State int);
+  }
+
+  public static class FakeXrExtensions.FakeGltfModelToken implements androidx.xr.extensions.asset.GltfModelToken {
+    ctor public FakeXrExtensions.FakeGltfModelToken(String);
+    method public String getUrl();
+  }
+
+  public static class FakeXrExtensions.FakeInputEvent implements androidx.xr.extensions.node.InputEvent {
+    ctor public FakeXrExtensions.FakeInputEvent();
+    method public int getAction();
+    method public androidx.xr.extensions.node.Vec3 getDirection();
+    method public int getDispatchFlags();
+    method public androidx.xr.extensions.node.InputEvent.HitInfo? getHitInfo();
+    method public androidx.xr.extensions.node.Vec3 getOrigin();
+    method public int getPointerType();
+    method public androidx.xr.extensions.node.InputEvent.HitInfo? getSecondaryHitInfo();
+    method public int getSource();
+    method public long getTimestamp();
+    method public void setDirection(androidx.xr.extensions.node.Vec3);
+    method public void setDispatchFlags(int);
+    method public void setFakeHitInfo(androidx.xr.scenecore.testing.FakeXrExtensions.FakeInputEvent.FakeHitInfo);
+    method public void setOrigin(androidx.xr.extensions.node.Vec3);
+    method public void setTimestamp(long);
+  }
+
+  public static class FakeXrExtensions.FakeInputEvent.FakeHitInfo implements androidx.xr.extensions.node.InputEvent.HitInfo {
+    ctor public FakeXrExtensions.FakeInputEvent.FakeHitInfo();
+    method public androidx.xr.extensions.node.Vec3? getHitPosition();
+    method public androidx.xr.extensions.node.Node getInputNode();
+    method public int getSubspaceImpressNodeId();
+    method public androidx.xr.extensions.node.Mat4f getTransform();
+    method public void setHitPosition(androidx.xr.extensions.node.Vec3?);
+    method public void setInputNode(androidx.xr.extensions.node.Node);
+    method public void setSubspaceImpressNodeId(int);
+    method public void setTransform(androidx.xr.extensions.node.Mat4f);
+  }
+
+  public static class FakeXrExtensions.FakeMediaPlayerExtensions implements androidx.xr.extensions.media.MediaPlayerExtensions {
+    ctor public FakeXrExtensions.FakeMediaPlayerExtensions();
+    method public androidx.xr.extensions.media.PointSourceAttributes? getPointSourceAttributes();
+    method public androidx.xr.extensions.media.SoundFieldAttributes? getSoundFieldAttributes();
+  }
+
+  public static final class FakeXrExtensions.FakeNode implements androidx.xr.extensions.node.Node {
+    method public int describeContents();
+    method public float getAlpha();
+    method public android.os.IBinder? getAnchorId();
+    method @Deprecated public androidx.xr.extensions.asset.EnvironmentToken? getEnvironment();
+    method public java.util.concurrent.Executor? getExecutor();
+    method @Deprecated public androidx.xr.extensions.asset.GltfModelToken? getGltfModel();
+    method public androidx.xr.extensions.Consumer<androidx.xr.extensions.node.InputEvent!>? getListener();
+    method public String? getName();
+    method public androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode? getParent();
+    method public androidx.xr.extensions.Consumer<java.lang.Integer!>? getPointerCaptureStateCallback();
+    method public androidx.xr.extensions.node.ReformOptions? getReformOptions();
+    method public android.view.SurfaceControlViewHost.SurfacePackage? getSurfacePackage();
+    method public java.util.concurrent.Executor? getTransformExecutor();
+    method public androidx.xr.extensions.Consumer<androidx.xr.extensions.node.NodeTransform!>? getTransformListener();
+    method public float getWOrientation();
+    method public float getXOrientation();
+    method public float getXPosition();
+    method public float getYOrientation();
+    method public float getYPosition();
+    method public float getZOrientation();
+    method public float getZPosition();
+    method public boolean isVisible();
+    method public void listenForInput(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.InputEvent!>, java.util.concurrent.Executor);
+    method public void requestPointerCapture(androidx.xr.extensions.Consumer<java.lang.Integer!>, java.util.concurrent.Executor);
+    method public void sendInputEvent(androidx.xr.extensions.node.InputEvent);
+    method public void sendTransformEvent(androidx.xr.scenecore.testing.FakeXrExtensions.FakeNodeTransform);
+    method public void setNonPointerFocusTarget(android.view.AttachedSurfaceControl);
+    method public void stopListeningForInput();
+    method public void stopPointerCapture();
+    method public java.io.Closeable subscribeToTransform(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.NodeTransform!>, java.util.concurrent.Executor);
+    method public void writeToParcel(android.os.Parcel, int);
+  }
+
+  public static class FakeXrExtensions.FakeNodeTransaction implements androidx.xr.extensions.node.NodeTransaction {
+  }
+
+  public static class FakeXrExtensions.FakeNodeTransform implements androidx.xr.extensions.node.NodeTransform {
+    ctor public FakeXrExtensions.FakeNodeTransform(androidx.xr.extensions.node.Mat4f);
+    method public long getTimestamp();
+    method public androidx.xr.extensions.node.Mat4f getTransform();
+  }
+
+  public static class FakeXrExtensions.FakePassthroughVisibilityState implements androidx.xr.extensions.environment.PassthroughVisibilityState {
+    ctor public FakeXrExtensions.FakePassthroughVisibilityState(@androidx.xr.extensions.environment.PassthroughVisibilityState.State int, float);
+  }
+
+  public static class FakeXrExtensions.FakeReformEvent implements androidx.xr.extensions.node.ReformEvent {
+    ctor public FakeXrExtensions.FakeReformEvent();
+    method public androidx.xr.extensions.node.Vec3 getCurrentRayDirection();
+    method public androidx.xr.extensions.node.Vec3 getCurrentRayOrigin();
+    method public int getId();
+    method public androidx.xr.extensions.node.Vec3 getInitialRayDirection();
+    method public androidx.xr.extensions.node.Vec3 getInitialRayOrigin();
+    method public androidx.xr.extensions.node.Quatf getProposedOrientation();
+    method public androidx.xr.extensions.node.Vec3 getProposedPosition();
+    method public androidx.xr.extensions.node.Vec3 getProposedScale();
+    method public androidx.xr.extensions.node.Vec3 getProposedSize();
+    method public int getState();
+    method public int getType();
+    method public void setProposedOrientation(androidx.xr.extensions.node.Quatf);
+    method public void setProposedPosition(androidx.xr.extensions.node.Vec3);
+    method public void setProposedScale(androidx.xr.extensions.node.Vec3);
+    method public void setState(int);
+    method public void setType(int);
+  }
+
+  public static class FakeXrExtensions.FakeReformOptions implements androidx.xr.extensions.node.ReformOptions {
+    method public androidx.xr.extensions.node.Vec3 getCurrentSize();
+    method public int getEnabledReform();
+    method public androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!> getEventCallback();
+    method public java.util.concurrent.Executor getEventExecutor();
+    method public float getFixedAspectRatio();
+    method public int getFlags();
+    method public androidx.xr.extensions.node.Vec3 getMaximumSize();
+    method public androidx.xr.extensions.node.Vec3 getMinimumSize();
+    method public androidx.xr.extensions.node.ReformOptions setCurrentSize(androidx.xr.extensions.node.Vec3);
+    method public androidx.xr.extensions.node.ReformOptions setEnabledReform(int);
+    method public androidx.xr.extensions.node.ReformOptions setEventCallback(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!>);
+    method public androidx.xr.extensions.node.ReformOptions setEventExecutor(java.util.concurrent.Executor);
+    method public androidx.xr.extensions.node.ReformOptions setFixedAspectRatio(float);
+    method public androidx.xr.extensions.node.ReformOptions setFlags(int);
+    method public androidx.xr.extensions.node.ReformOptions setMaximumSize(androidx.xr.extensions.node.Vec3);
+    method public androidx.xr.extensions.node.ReformOptions setMinimumSize(androidx.xr.extensions.node.Vec3);
+  }
+
+  public static class FakeXrExtensions.FakeSoundPoolExtensions implements androidx.xr.extensions.media.SoundPoolExtensions {
+    ctor public FakeXrExtensions.FakeSoundPoolExtensions();
+    method public void setPlayAsPointSourceResult(int);
+    method public void setPlayAsSoundFieldResult(int);
+    method public void setSourceType(@androidx.xr.extensions.media.SpatializerExtensions.SourceType int);
+  }
+
+  public static class FakeXrExtensions.FakeSpatialAudioExtensions implements androidx.xr.extensions.media.XrSpatialAudioExtensions {
+    ctor public FakeXrExtensions.FakeSpatialAudioExtensions();
+    method public void setFakeAudioTrackExtensions(androidx.xr.scenecore.testing.FakeXrExtensions.FakeAudioTrackExtensions);
+    field public final androidx.xr.scenecore.testing.FakeXrExtensions.FakeMediaPlayerExtensions mediaPlayerExtensions;
+    field public final androidx.xr.scenecore.testing.FakeXrExtensions.FakeSoundPoolExtensions soundPoolExtensions;
+  }
+
+  public static class FakeXrExtensions.FakeSpatialState implements androidx.xr.extensions.space.SpatialState {
+    ctor public FakeXrExtensions.FakeSpatialState();
+    method public void setAllSpatialCapabilities(boolean);
+    method public void setBounds(androidx.xr.extensions.space.Bounds);
+    method public void setEnvironmentVisibility(androidx.xr.extensions.environment.EnvironmentVisibilityState);
+    method public void setPassthroughVisibility(androidx.xr.extensions.environment.PassthroughVisibilityState);
+    method public void setSpatialCapabilities(androidx.xr.extensions.space.SpatialCapabilities);
+  }
+
+  public enum FakeXrExtensions.SpaceMode {
+    enum_constant public static final androidx.xr.scenecore.testing.FakeXrExtensions.SpaceMode FULL_SPACE;
+    enum_constant public static final androidx.xr.scenecore.testing.FakeXrExtensions.SpaceMode HOME_SPACE;
+    enum_constant public static final androidx.xr.scenecore.testing.FakeXrExtensions.SpaceMode NONE;
+  }
+
+}
+
diff --git a/xr/scenecore/scenecore-testing/build.gradle b/xr/scenecore/scenecore-testing/build.gradle
new file mode 100644
index 0000000..998dd30
--- /dev/null
+++ b/xr/scenecore/scenecore-testing/build.gradle
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+}
+
+dependencies {
+    api(project(":xr:scenecore:scenecore"))
+
+    implementation(libs.testExtTruth)
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation("androidx.concurrent:concurrent-futures:1.0.0")
+    implementation("com.google.ar:impress:0.0.1")
+}
+
+android {
+    defaultConfig {
+        // TODO: This should be lower, possibly 21.
+        //       Address API calls that require higher versions.
+        minSdkVersion 30
+    }
+    namespace = "androidx.xr.scenecore.testing"
+}
+
+androidx {
+    name = "XR SceneCore Testing"
+    type = LibraryType.PUBLISHED_TEST_LIBRARY
+    inceptionYear = "2024"
+    description = "Libraries to aid in unit testing SceneCore clients."
+
+    // TODO: b/379715750 - Remove this flag once the deprecated methods have been removed from the API.
+    failOnDeprecationWarnings = false
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeImpressApi.java b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeImpressApi.java
new file mode 100644
index 0000000..adf600a6
--- /dev/null
+++ b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeImpressApi.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.testing;
+
+import android.view.Surface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.concurrent.futures.ResolvableFuture;
+
+import com.google.ar.imp.apibindings.ImpressApi;
+import com.google.ar.imp.view.View;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Fake implementation of the JNI API for communicating with the Impress Split Engine instance for
+ * testing purposes.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeImpressApi implements ImpressApi {
+
+    // ResolvableFuture is marked as RestrictTo(LIBRARY_GROUP_PREFIX), which is intended for classes
+    // within AndroidX. We're in the process of migrating to AndroidX. Without suppressing this
+    // warning, however, we get a build error - go/bugpattern/RestrictTo.
+    @SuppressWarnings("RestrictTo")
+    static class AnimationInProgress {
+        public String name;
+        public ResolvableFuture<Void> fireOnDone;
+    }
+
+    // Map of model tokens to the list of impress nodes that are instances of that model.
+    private final Map<Long, List<Integer>> gltfModels = new HashMap<>();
+    // Map of impress nodes to their parent impress nodes.
+    private final Map<Integer, Integer> impressNodes = new HashMap<>();
+
+    // Map of impress nodes and animations that are currently playing (non looping)
+    final Map<Integer, AnimationInProgress> impressAnimatedNodes = new HashMap<>();
+
+    // Map of impress nodes and animations that are currently playing (looping)
+    final Map<Integer, AnimationInProgress> impressLoopAnimatedNodes = new HashMap<>();
+
+    private int nextModelId = 1;
+    private int nextNodeId = 1;
+
+    @Override
+    public void setup(@NonNull View view) {}
+
+    @Override
+    public void onResume() {}
+
+    @Override
+    public void onPause() {}
+
+    @Override
+    @NonNull
+    @SuppressWarnings({"RestrictTo", "AsyncSuffixFuture"})
+    public ListenableFuture<Long> loadGltfModel(@NonNull String name) {
+        long modelToken = nextModelId++;
+        gltfModels.put(modelToken, new ArrayList<>());
+        // TODO(b/352827267): Enforce minSDK API strategy - go/androidx-api-guidelines#compat-newapi
+        ResolvableFuture<Long> ret = ResolvableFuture.create();
+        ret.set(modelToken);
+
+        return ret;
+    }
+
+    @Override
+    public void releaseGltfModel(long modelToken) {
+        if (!gltfModels.containsKey(modelToken)) {
+            throw new IllegalArgumentException("Model token not found");
+        }
+        gltfModels.remove(modelToken);
+    }
+
+    @Override
+    public int instanceGltfModel(long modelToken) {
+        return instanceGltfModel(modelToken, true);
+    }
+
+    @Override
+    public int instanceGltfModel(long modelToken, boolean enableCollider) {
+        if (!gltfModels.containsKey(modelToken)) {
+            throw new IllegalArgumentException("Model token not found");
+        }
+        int entityId = nextNodeId++;
+        gltfModels.get(modelToken).add(entityId);
+        impressNodes.put(entityId, null);
+        return entityId;
+    }
+
+    @Override
+    public void setGltfModelColliderEnabled(int impressNode, boolean enableCollider) {
+        throw new IllegalArgumentException("not implemented");
+    }
+
+    @Override
+    @NonNull
+    @SuppressWarnings({"RestrictTo", "AsyncSuffixFuture"})
+    public ListenableFuture<Void> animateGltfModel(
+            int impressNode, @Nullable String animationName, boolean loop) {
+        ResolvableFuture<Void> future = ResolvableFuture.create();
+        if (impressNodes.get(impressNode) == null) {
+            future.setException(new IllegalArgumentException("Impress node not found"));
+            return future;
+        }
+
+        AnimationInProgress animationInProgress = new AnimationInProgress();
+        animationInProgress.name = animationName;
+        animationInProgress.fireOnDone = future;
+        if (loop) {
+            impressLoopAnimatedNodes.put(impressNode, animationInProgress);
+        } else {
+            impressAnimatedNodes.put(impressNode, animationInProgress);
+        }
+        return future;
+    }
+
+    @Override
+    public void stopGltfModelAnimation(int impressNode) {
+        if (impressNodes.get(impressNode) == null) {
+            throw new IllegalArgumentException("Impress node not found");
+        } else if (!impressAnimatedNodes.containsKey(impressNode)
+                && !impressLoopAnimatedNodes.containsKey(impressNode)) {
+            throw new IllegalArgumentException("Impress node is not animating");
+        } else if (impressAnimatedNodes.containsKey(impressNode)) {
+            impressAnimatedNodes.remove(impressNode);
+        } else if (impressLoopAnimatedNodes.containsKey(impressNode)) {
+            impressLoopAnimatedNodes.remove(impressNode);
+        }
+    }
+
+    @Override
+    public int createImpressNode() {
+        int entityId = nextNodeId++;
+        impressNodes.put(entityId, null);
+        return entityId;
+    }
+
+    @Override
+    public void destroyImpressNode(int impressNode) {
+        if (!impressNodes.containsKey(impressNode)) {
+            throw new IllegalArgumentException("Impress node not found");
+        }
+        for (Map.Entry<Long, List<Integer>> pair : gltfModels.entrySet()) {
+            if (pair.getValue().contains(impressNode)) {
+                pair.getValue().remove(impressNode);
+            }
+        }
+        for (Map.Entry<Integer, Integer> pair : impressNodes.entrySet()) {
+            if (pair.getValue() != null && pair.getValue().equals((Integer) impressNode)) {
+                pair.setValue(null);
+            }
+        }
+        impressNodes.remove(impressNode);
+    }
+
+    @Override
+    public void setImpressNodeParent(int impressNodeChild, int impressNodeParent) {
+        if (!impressNodes.containsKey(impressNodeChild)
+                || !impressNodes.containsKey(impressNodeParent)) {
+            throw new IllegalArgumentException("Impress node(s) not found");
+        }
+        impressNodes.put(impressNodeChild, impressNodeParent);
+    }
+
+    @NonNull
+    public List<Integer> getImpressNodesForToken(long modelToken) {
+        return gltfModels.get(modelToken);
+    }
+
+    public boolean impressNodeHasParent(int impressNode) {
+        return impressNodes.containsKey(impressNode) && impressNodes.get(impressNode) != null;
+    }
+
+    public int impressNodeAnimatingSize() {
+        return impressAnimatedNodes.size();
+    }
+
+    public int impressNodeLoopAnimatingSize() {
+        return impressLoopAnimatedNodes.size();
+    }
+
+    @Override
+    public int createStereoSurface(@StereoMode int mode) {
+        return nextNodeId++;
+    }
+
+    @Override
+    @NonNull
+    public Surface getSurfaceFromStereoSurface(int panelImpressNode) {
+        throw new IllegalArgumentException("not implemented");
+    }
+
+    @Override
+    public void setStereoModeForStereoSurface(int panelImpressNode, @StereoMode int mode) {
+        throw new IllegalArgumentException("not implemented");
+    }
+
+    @Override
+    public void setCanvasDimensionsForStereoSurface(
+            int panelImpressNode, float width, float height) {
+        throw new IllegalArgumentException("not implemented");
+    }
+}
diff --git a/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeScheduledExecutorService.java b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeScheduledExecutorService.java
new file mode 100644
index 0000000..802791d
--- /dev/null
+++ b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeScheduledExecutorService.java
@@ -0,0 +1,396 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.PriorityBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Fake implementation of {@link ScheduledExecutorService} that lets tests control when tasks are
+ * executed.
+ */
+@SuppressWarnings("NotCloseable")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeScheduledExecutorService extends AbstractExecutorService
+        implements ScheduledExecutorService, AutoCloseable {
+
+    private static final TimeUnit CLOCK_UNIT = MILLISECONDS;
+
+    private static long toClockUnit(Duration duration) {
+        return duration.toMillis();
+    }
+
+    private static Duration durationFromClockUnit(long durationClockUnit) {
+        return Duration.ofMillis(durationClockUnit);
+    }
+
+    private final Clock clock;
+    private final Queue<Runnable> executeQueue = new ConcurrentLinkedQueue<>();
+    private final PriorityBlockingQueue<DelayedFuture<?>> scheduledQueue =
+            new PriorityBlockingQueue<>();
+
+    private final AtomicLong nextSequenceId = new AtomicLong(0);
+    private volatile boolean running = true;
+
+    public FakeScheduledExecutorService() {
+        this.clock = new Clock();
+    }
+
+    @Override
+    public boolean isShutdown() {
+        return !running;
+    }
+
+    @Override
+    public boolean isTerminated() {
+        return isShutdown() && isEmpty();
+    }
+
+    @Override
+    public void shutdown() {
+        running = false;
+    }
+
+    @Override
+    public void close() {
+        shutdown();
+    }
+
+    @Override
+    @NonNull
+    public List<Runnable> shutdownNow() {
+        running = false;
+        List<Runnable> commands = Lists.newArrayList();
+        commands.addAll(executeQueue);
+        commands.addAll(scheduledQueue);
+        executeQueue.clear();
+        scheduledQueue.clear();
+        return commands;
+    }
+
+    @Override
+    public boolean awaitTermination(long timeout, @Nullable TimeUnit unit) {
+        checkState(!running);
+        while (!executeQueue.isEmpty()) {
+            runNext();
+        }
+        simulateSleepExecutingAllTasks(durationFromClockUnit(timeout));
+        return isEmpty();
+    }
+
+    @Override
+    public void execute(@Nullable Runnable command) {
+        assertRunning();
+        executeQueue.add(command);
+    }
+
+    private void assertRunning() {
+        if (!running) {
+            throw new RejectedExecutionException();
+        }
+    }
+
+    @Override
+    @NonNull
+    public ScheduledFuture<?> schedule(
+            @Nullable Runnable command, long delay, @Nullable TimeUnit unit) {
+        assertRunning();
+        DelayedFuture<?> future = new DelayedFuture<>(command, delay, unit);
+        scheduledQueue.add(future);
+        return future;
+    }
+
+    @Override
+    @NonNull
+    public <V> ScheduledFuture<V> schedule(
+            @Nullable Callable<V> callable, long delay, @Nullable TimeUnit unit) {
+        assertRunning();
+        DelayedFuture<V> future = new DelayedCallable<V>(callable, delay, unit);
+        scheduledQueue.add(future);
+        return future;
+    }
+
+    @Override
+    @NonNull
+    public ScheduledFuture<?> scheduleAtFixedRate(
+            @Nullable Runnable command, long initialDelay, long period, @Nullable TimeUnit unit) {
+        throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    @NonNull
+    public ScheduledFuture<?> scheduleWithFixedDelay(
+            @Nullable Runnable command, long initialDelay, long delay, @Nullable TimeUnit unit) {
+        throw new UnsupportedOperationException("not implemented");
+    }
+
+    /** Returns true if the {@link #execute} queue contains at least one runnable. */
+    public boolean hasNext() {
+        return !executeQueue.isEmpty();
+    }
+
+    /** Runs the next runnable in the {@link #execute} queue. */
+    public void runNext() {
+        checkState(!executeQueue.isEmpty(), "execute queue must not be empty");
+        Runnable runnable = executeQueue.remove();
+        runTaskWithInterruptIsolation(runnable);
+    }
+
+    /** Runs all of the runnables that {@link #execute} enqueued. */
+    public void runAll() {
+        while (hasNext()) {
+            runNext();
+        }
+    }
+
+    /** Returns whether any runnable is in the {@link #execute} or {@link #schedule} queue. */
+    @CheckReturnValue
+    public boolean isEmpty() {
+        return executeQueue.isEmpty() && scheduledQueue.isEmpty();
+    }
+
+    /**
+     * Executes tasks from the {@link #schedule} queue until the given amount of simulated time has
+     * passed.
+     */
+    public void simulateSleepExecutingAllTasks(@NonNull Duration duration) {
+        long timeout = toClockUnit(duration);
+        checkArgument(timeout >= 0, "timeout (%s) cannot be negative", timeout);
+
+        long stopTime = clock.currentTimeMillis() + CLOCK_UNIT.toMillis(timeout);
+        boolean done = false;
+
+        while (!done) {
+            long delay = (stopTime - clock.currentTimeMillis());
+            if (delay >= 0 && simulateSleepExecutingAtMostOneTask(durationFromClockUnit(delay))) {
+                continue;
+            } else {
+                done = true;
+            }
+        }
+    }
+
+    /**
+     * Simulates sleeping up to the given timeout before executing the next scheduled task, if any.
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public boolean simulateSleepExecutingAtMostOneTask(@NonNull Duration duration) {
+        long timeout = toClockUnit(duration);
+        checkArgument(timeout >= 0, "timeout (%s) cannot be negative", timeout);
+        if (scheduledQueue.isEmpty()) {
+            clock.advanceBy(duration);
+            return false;
+        }
+
+        DelayedFuture<?> future = scheduledQueue.peek();
+        long delay = future.getDelay(CLOCK_UNIT);
+        if (delay > timeout) {
+            // Next event is too far in the future; delay the entire time
+            clock.advanceBy(duration);
+            return false;
+        }
+
+        scheduledQueue.poll();
+        runTaskWithInterruptIsolation(future);
+
+        return true;
+    }
+
+    /**
+     * Simulates sleeping as long as necessary before executing the next scheduled task. Does
+     * nothing if the {@link #schedule} queue is empty.
+     */
+    public boolean simulateSleepExecutingAtMostOneTask() {
+        if (scheduledQueue.isEmpty()) {
+            return false;
+        }
+
+        DelayedFuture<?> future = scheduledQueue.poll();
+        runTaskWithInterruptIsolation(future);
+        return true;
+    }
+
+    private class DelayedFuture<T> implements ScheduledFuture<T>, Runnable {
+        protected final long timeToRun;
+        private final long sequenceId;
+        private final Runnable command;
+        private boolean cancelled;
+        private boolean done;
+
+        public DelayedFuture(Runnable command, long delay, TimeUnit unit) {
+            checkArgument(delay >= 0, "delay (%s) cannot be negative", delay);
+
+            this.command = command;
+            timeToRun = clock.currentTimeMillis() + unit.toMillis(delay);
+            sequenceId = nextSequenceId.getAndIncrement();
+        }
+
+        @Override
+        public long getDelay(TimeUnit unit) {
+            return unit.convert(timeToRun - clock.currentTimeMillis(), MILLISECONDS);
+        }
+
+        protected void maybeReschedule() {
+            done = true;
+        }
+
+        @Override
+        public void run() {
+            if (clock.currentTimeMillis() < timeToRun) {
+                clock.advanceBy(durationFromClockUnit(timeToRun - clock.currentTimeMillis()));
+            }
+            command.run();
+            maybeReschedule();
+        }
+
+        @Override
+        public boolean cancel(boolean mayInterruptIfRunning) {
+            cancelled = true;
+            done = true;
+            return scheduledQueue.remove(this);
+        }
+
+        @Override
+        public boolean isCancelled() {
+            return cancelled;
+        }
+
+        @Override
+        public boolean isDone() {
+            return done;
+        }
+
+        @Override
+        public T get() throws InterruptedException, ExecutionException {
+            return null;
+        }
+
+        @Override
+        public T get(long timeout, TimeUnit unit)
+                throws InterruptedException, ExecutionException, TimeoutException {
+            return null;
+        }
+
+        @Override
+        public int compareTo(Delayed other) {
+            if (other == this) {
+                return 0;
+            }
+            DelayedFuture<?> that = (DelayedFuture<?>) other;
+            long diff = timeToRun - that.timeToRun;
+            if (diff < 0) {
+                return -1;
+            } else if (diff > 0) {
+                return 1;
+            } else if (sequenceId < that.sequenceId) {
+                return -1;
+            } else {
+                return 1;
+            }
+        }
+    }
+
+    private class DelayedCallable<T> extends DelayedFuture<T> {
+        private final FutureTask<T> task;
+
+        private DelayedCallable(FutureTask<T> task, long delay, TimeUnit unit) {
+            super(task, delay, unit);
+            this.task = task;
+        }
+
+        public DelayedCallable(Callable<T> callable, long delay, TimeUnit unit) {
+            this(new FutureTask<T>(callable), delay, unit);
+        }
+
+        @Override
+        public boolean cancel(boolean mayInterruptIfRunning) {
+            task.cancel(mayInterruptIfRunning);
+            return super.cancel(mayInterruptIfRunning);
+        }
+
+        @Override
+        public T get() throws InterruptedException, ExecutionException {
+            return task.get();
+        }
+
+        @Override
+        public T get(long timeout, TimeUnit unit)
+                throws InterruptedException, ExecutionException, TimeoutException {
+            return task.get(timeout, unit);
+        }
+    }
+
+    private static class Clock {
+        private final AtomicReference<Instant> nowReference = new AtomicReference<>();
+
+        public Clock() {
+            setTo(Instant.EPOCH);
+        }
+
+        public long currentTimeMillis() {
+            return nowReference.get().toEpochMilli();
+        }
+
+        public void advanceBy(Duration duration) {
+            nowReference.getAndUpdate(now -> now.plus(duration));
+        }
+
+        public void setTo(Instant instant) {
+            nowReference.set(instant);
+        }
+    }
+
+    /** Clears this thread's interrupt bit, runs the task, and restores any previous interrupt. */
+    private void runTaskWithInterruptIsolation(Runnable task) {
+        boolean interruptBitWasSet = Thread.interrupted();
+        try {
+            task.run();
+        } finally {
+            if (interruptBitWasSet) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeXrExtensions.java b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeXrExtensions.java
new file mode 100644
index 0000000..843fd12
--- /dev/null
+++ b/xr/scenecore/scenecore-testing/src/main/java/androidx/xr/scenecore/testing/FakeXrExtensions.java
@@ -0,0 +1,1872 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.testing;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.media.AudioTrack;
+import android.media.MediaPlayer;
+import android.media.SoundPool;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.view.AttachedSurfaceControl;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.Config;
+import androidx.xr.extensions.Consumer;
+import androidx.xr.extensions.XrExtensionResult;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.media.AudioTrackExtensions;
+import androidx.xr.extensions.media.MediaPlayerExtensions;
+import androidx.xr.extensions.media.PointSourceAttributes;
+import androidx.xr.extensions.media.SoundFieldAttributes;
+import androidx.xr.extensions.media.SoundPoolExtensions;
+import androidx.xr.extensions.media.SpatializerExtensions;
+import androidx.xr.extensions.media.XrSpatialAudioExtensions;
+import androidx.xr.extensions.node.InputEvent;
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.node.NodeTransform;
+import androidx.xr.extensions.node.Quatf;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.extensions.passthrough.PassthroughState;
+import androidx.xr.extensions.space.ActivityPanel;
+import androidx.xr.extensions.space.ActivityPanelLaunchParameters;
+import androidx.xr.extensions.space.Bounds;
+import androidx.xr.extensions.space.HitTestResult;
+import androidx.xr.extensions.space.SpatialCapabilities;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.extensions.splitengine.SplitEngineBridge;
+import androidx.xr.extensions.subspace.Subspace;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+import java.io.Closeable;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+
+/**
+ * A fake for the XrExtensions.
+ *
+ * <p>This has fake implementations for a subset of the XrExtension capability that is used by the
+ * JXRCore runtime for AndroidXR.
+ */
+@SuppressWarnings("deprecation")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FakeXrExtensions implements XrExtensions {
+    private static final String NOT_IMPLEMENTED_IN_FAKE =
+            "This function is not implemented yet in FakeXrExtensions.  Please add an"
+                    + " implementation if support is desired for testing.";
+
+    @NonNull public final List<FakeNode> createdNodes = new ArrayList<>();
+
+    @NonNull public final List<FakeGltfModelToken> createdGltfModelTokens = new ArrayList<>();
+
+    @NonNull public final List<FakeEnvironmentToken> createdEnvironmentTokens = new ArrayList<>();
+
+    @NonNull public final Map<Activity, FakeActivityPanel> activityPanelMap = new HashMap<>();
+
+    FakeNode fakeTaskNode = null;
+    FakeNode fakeEnvironmentNode = null;
+    FakeNode fakeNodeForMainWindow = null;
+
+    // TODO: b/370033054 - fakeSpatialState should be updated according to some fake extensions
+    // calls
+    // like requestFullSpaceMode after migration to SpatialState API
+    @NonNull public final FakeSpatialState fakeSpatialState = new FakeSpatialState();
+
+    // Technically this could be set per-activity, but we're assuming that there's a single activity
+    // associated with each JXRCore session, so we're only tracking it once for now.
+    SpaceMode spaceMode = SpaceMode.NONE;
+
+    int mainWindowWidth = 0;
+    int mainWindowHeight = 0;
+
+    Consumer<SpatialState> spatialStateCallback = null;
+
+    float preferredAspectRatioHsm = 0.0f;
+    int openXrWorldSpaceType = 0;
+    FakeNodeTransaction lastFakeNodeTransaction = null;
+
+    @NonNull
+    public final FakeSpatialAudioExtensions fakeSpatialAudioExtensions =
+            new FakeSpatialAudioExtensions();
+
+    @Nullable
+    public FakeNode getFakeEnvironmentNode() {
+        return fakeEnvironmentNode;
+    }
+
+    @Nullable
+    public FakeNode getFakeNodeForMainWindow() {
+        return fakeNodeForMainWindow;
+    }
+
+    @Override
+    public int getApiVersion() {
+        // The API surface is aligned with the initial XRU release.
+        return 1;
+    }
+
+    @Override
+    @NonNull
+    public Node createNode() {
+        FakeNode node = new FakeNode();
+        createdNodes.add(node);
+        return node;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction createNodeTransaction() {
+        lastFakeNodeTransaction = new FakeNodeTransaction();
+        return lastFakeNodeTransaction;
+    }
+
+    @Override
+    @NonNull
+    public Subspace createSubspace(@NonNull SplitEngineBridge splitEngineBridge, int subspaceId) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @NonNull
+    @Deprecated
+    public Bundle setMainPanelCurvatureRadius(@NonNull Bundle bundle, float panelCurvatureRadius) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    @NonNull
+    public Config getConfig() {
+        return new FakeConfig();
+    }
+
+    @NonNull
+    public SpaceMode getSpaceMode() {
+        return spaceMode;
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void setMainWindowSize(@NonNull Activity activity, int width, int height) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void setMainWindowSize(
+            @NonNull Activity activity,
+            int width,
+            int height,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mainWindowWidth = width;
+        mainWindowHeight = height;
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void setMainWindowCurvatureRadius(@NonNull Activity activity, float curvatureRadius) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    public int getMainWindowWidth() {
+        return mainWindowWidth;
+    }
+
+    public int getMainWindowHeight() {
+        return mainWindowHeight;
+    }
+
+    @Override
+    public void getBounds(
+            @NonNull Activity activity,
+            @NonNull Consumer<Bounds> callback,
+            @NonNull Executor executor) {
+        callback.accept(
+                (spaceMode == SpaceMode.FULL_SPACE
+                        ? new Bounds(
+                                Float.POSITIVE_INFINITY,
+                                Float.POSITIVE_INFINITY,
+                                Float.POSITIVE_INFINITY)
+                        : new Bounds(1f, 1f, 1f)));
+    }
+
+    private SpatialCapabilities getCapabilities(boolean allowAll) {
+        return new SpatialCapabilities() {
+            @Override
+            public boolean get(int capQuery) {
+                return allowAll;
+            }
+        };
+    }
+
+    @Override
+    public void getSpatialCapabilities(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialCapabilities> callback,
+            @NonNull Executor executor) {
+        callback.accept(fakeSpatialState.getSpatialCapabilities());
+    }
+
+    @Override
+    @NonNull
+    public SpatialState getSpatialState(@NonNull Activity activity) {
+        return fakeSpatialState;
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public boolean canEmbedActivityPanel(@NonNull Activity activity) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public boolean requestFullSpaceMode(@NonNull Activity activity) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void requestFullSpaceMode(
+            @NonNull Activity activity,
+            boolean requestEnter,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        FakeSpatialState spatialState = new FakeSpatialState();
+        spatialState.bounds =
+                requestEnter
+                        ? new Bounds(
+                                Float.POSITIVE_INFINITY,
+                                Float.POSITIVE_INFINITY,
+                                Float.POSITIVE_INFINITY)
+                        : new Bounds(10f, 10f, 10f);
+        spatialState.capabilities = getCapabilities(requestEnter);
+        sendSpatialState(spatialState);
+        spaceMode = requestEnter ? SpaceMode.FULL_SPACE : SpaceMode.HOME_SPACE;
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public boolean requestHomeSpaceMode(@NonNull Activity activity) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void setSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<androidx.xr.extensions.space.SpatialStateEvent> callback,
+            @NonNull Executor executor) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    @SuppressLint("PairedRegistration")
+    public void registerSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialState> callback,
+            @NonNull Executor executor) {
+        // note that we assume this is only called for the (single) primary activity associated with
+        // the JXRCore session and we also don't honor the executor here
+        spatialStateCallback = callback;
+    }
+
+    @Override
+    public void clearSpatialStateCallback(@NonNull Activity activity) {
+        spatialStateCallback = null;
+    }
+
+    // Method for tests to call to trigger the spatial state callback.
+    // This should probably be called on the provided executor.
+    public void sendSpatialState(@NonNull SpatialState spatialState) {
+        if (spatialStateCallback != null) {
+            spatialStateCallback.accept(spatialState);
+        }
+    }
+
+    @Nullable
+    public Consumer<SpatialState> getSpatialStateCallback() {
+        return spatialStateCallback;
+    }
+
+    private XrExtensionResult createAsyncResult() {
+        return new XrExtensionResult() {
+            @Override
+            public int getResult() {
+                return XrExtensionResult.XR_RESULT_SUCCESS;
+            }
+        };
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void attachSpatialScene(
+            @NonNull Activity activity, @NonNull Node sceneNode, @NonNull Node windowNode) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void attachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Node sceneNode,
+            @NonNull Node windowNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        fakeTaskNode = (FakeNode) sceneNode;
+        fakeTaskNode.name = "taskNode";
+
+        fakeNodeForMainWindow = (FakeNode) windowNode;
+        fakeNodeForMainWindow.name = "nodeForMainWindow";
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void detachSpatialScene(@NonNull Activity activity) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void detachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        fakeTaskNode = null;
+        fakeNodeForMainWindow = null;
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    @Nullable
+    public FakeNode getFakeTaskNode() {
+        return fakeTaskNode;
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void attachSpatialEnvironment(
+            @NonNull Activity activity, @NonNull Node environmentNode) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void attachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Node environmentNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        fakeEnvironmentNode = (FakeNode) environmentNode;
+        fakeEnvironmentNode.name = "environmentNode";
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    /**
+     * @deprecated This method is no longer supported.
+     */
+    @Override
+    @Deprecated
+    public void detachSpatialEnvironment(@NonNull Activity activity) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void detachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        fakeEnvironmentNode = null;
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    /**
+     * Suppressed to allow CompletableFuture.
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @SuppressWarnings({"AndroidJdkLibsChecker", "BadFuture"})
+    @Override
+    @NonNull
+    @Deprecated
+    public CompletableFuture</* @Nullable */ androidx.xr.extensions.asset.GltfModelToken>
+            loadGltfModel(
+                    @Nullable InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    @Nullable String url) {
+        FakeGltfModelToken modelToken = new FakeGltfModelToken(url);
+        createdGltfModelTokens.add(modelToken);
+        return CompletableFuture.completedFuture(modelToken);
+    }
+
+    /**
+     * Suppressed to allow CompletableFuture.
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @Override
+    @NonNull
+    @Deprecated
+    public CompletableFuture</* @Nullable */ SceneViewerResult> displayGltfModel(
+            Activity activity, androidx.xr.extensions.asset.GltfModelToken gltfModel) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * Suppressed to allow CompletableFuture.
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @SuppressWarnings({"AndroidJdkLibsChecker", "BadFuture"})
+    @Override
+    @NonNull
+    @Deprecated
+    //  public ListenableFuture</* @Nullable */ androidx.xr.extensions.asset.EnvironmentToken>
+    // loadEnvironment(
+    public CompletableFuture</* @Nullable */ androidx.xr.extensions.asset.EnvironmentToken>
+            loadEnvironment(
+                    @Nullable InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    @Nullable String url) {
+        FakeEnvironmentToken imageToken = new FakeEnvironmentToken(url);
+        createdEnvironmentTokens.add(imageToken);
+        //    return immediateFuture(imageToken);
+        return CompletableFuture.completedFuture(imageToken);
+    }
+
+    /**
+     * Suppressed to allow CompletableFuture.
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @Override
+    @NonNull
+    @Deprecated
+    public CompletableFuture</* @Nullable */ androidx.xr.extensions.asset.EnvironmentToken>
+            loadEnvironment(
+                    InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    String url,
+                    int textureWidth,
+                    int textureHeight) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * Suppressed to allow CompletableFuture.
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @Override
+    @Deprecated
+    @NonNull
+    public CompletableFuture</* @Nullable */ androidx.xr.extensions.asset.SceneToken>
+            loadImpressScene(InputStream asset, int regionSizeBytes, int regionOffsetBytes) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    @NonNull
+    public SplitEngineBridge createSplitEngineBridge() {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    /**
+     * Returns a FakeNode with corresponding gltfModelToken if it was created and found
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @NonNull
+    @Deprecated
+    public FakeNode testGetNodeWithGltfToken(
+            @NonNull androidx.xr.extensions.asset.GltfModelToken token) {
+        for (FakeNode node : createdNodes) {
+            if (node.gltfModel != null && node.gltfModel.equals(token)) {
+                return node;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns a FakeNode with corresponding environmentToken if it was created and found
+     *
+     * @deprecated This method is no longer supported.
+     */
+    @NonNull
+    @Deprecated
+    public FakeNode testGetNodeWithEnvironmentToken(
+            @NonNull androidx.xr.extensions.asset.EnvironmentToken token) {
+        for (FakeNode node : createdNodes) {
+            if (node.environment != null && node.environment.equals(token)) {
+                return node;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    @NonNull
+    public ActivityPanel createActivityPanel(
+            @NonNull Activity host, @NonNull ActivityPanelLaunchParameters launchParameters) {
+        FakeActivityPanel fakeActivityPanel = new FakeActivityPanel(createNode());
+        activityPanelMap.put(host, fakeActivityPanel);
+        return fakeActivityPanel;
+    }
+
+    @NonNull
+    public FakeActivityPanel getActivityPanelForHost(@NonNull Activity host) {
+        return activityPanelMap.get(host);
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions createReformOptions(
+            @NonNull Consumer<ReformEvent> callback, @NonNull Executor executor) {
+        return new FakeReformOptions(callback, executor);
+    }
+
+    @Override
+    public void addFindableView(@NonNull View view, @NonNull ViewGroup group) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void removeFindableView(@NonNull View view, @NonNull ViewGroup group) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    @Nullable
+    public Node getSurfaceTrackingNode(@NonNull View view) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void hitTest(
+            @NonNull Activity activity,
+            @NonNull Vec3 origin,
+            @NonNull Vec3 direction,
+            @NonNull Consumer<HitTestResult> callback,
+            @NonNull Executor executor) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public int getOpenXrWorldSpaceType() {
+        return openXrWorldSpaceType;
+    }
+
+    public void setOpenXrWorldSpaceType(int openXrWorldSpaceType) {
+        this.openXrWorldSpaceType = openXrWorldSpaceType;
+    }
+
+    @Override
+    @NonNull
+    public Bundle setFullSpaceMode(@NonNull Bundle bundle) {
+        return bundle;
+    }
+
+    @Override
+    @NonNull
+    public Bundle setFullSpaceModeWithEnvironmentInherited(@NonNull Bundle bundle) {
+        return bundle;
+    }
+
+    @Override
+    public void setPreferredAspectRatio(@NonNull Activity activity, float preferredRatio) {
+        throw new UnsupportedOperationException(NOT_IMPLEMENTED_IN_FAKE);
+    }
+
+    @Override
+    public void setPreferredAspectRatio(
+            @NonNull Activity activity,
+            float preferredRatio,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        preferredAspectRatioHsm = preferredRatio;
+
+        executor.execute(() -> callback.accept(createAsyncResult()));
+    }
+
+    public float getPreferredAspectRatio() {
+        return preferredAspectRatioHsm;
+    }
+
+    @NonNull
+    @Override
+    public XrSpatialAudioExtensions getXrSpatialAudioExtensions() {
+        return fakeSpatialAudioExtensions;
+    }
+
+    /** Tracks whether an Activity has requested a mode for when it's focused. */
+    public enum SpaceMode {
+        NONE,
+        HOME_SPACE,
+        FULL_SPACE
+    }
+
+    /** Fake implementation of Extensions Config. */
+    public static class FakeConfig implements Config {
+        public static final float DEFAULT_PIXELS_PER_METER = 1f;
+
+        @Override
+        public float defaultPixelsPerMeter(float density) {
+            return DEFAULT_PIXELS_PER_METER;
+        }
+    }
+
+    /** A fake implementation of Closeable. */
+    @SuppressWarnings("NotCloseable")
+    public static class FakeCloseable implements Closeable {
+        boolean closed = false;
+
+        @Override
+        public void close() {
+            closed = true;
+        }
+
+        public boolean isClosed() {
+            return closed;
+        }
+    }
+
+    /** A fake implementation of the XR extensions Node. */
+    @SuppressWarnings("ParcelCreator")
+    public static final class FakeNode implements Node {
+        FakeNode parent = null;
+        float xPosition = 0.0f;
+        float yPosition = 0.0f;
+        float zPosition = 0.0f;
+        float xOrientation = 0.0f;
+        float yOrientation = 0.0f;
+        float zOrientation = 0.0f;
+        float wOrientation = 1.0f;
+        float xScale = 1.0f;
+        float yScale = 1.0f;
+        float zScale = 1.0f;
+        float cornerRadius = 0.0f;
+        boolean isVisible = false;
+        float alpha = 1.0f;
+        androidx.xr.extensions.asset.GltfModelToken gltfModel = null;
+        IBinder anchorId = null;
+        String name = null;
+        float passthroughOpacity = 1.0f;
+        @PassthroughState.Mode int passthroughMode = 0;
+        SurfaceControlViewHost.SurfacePackage surfacePackage = null;
+        androidx.xr.extensions.asset.EnvironmentToken environment = null;
+        Consumer<InputEvent> listener = null;
+        Consumer<NodeTransform> transformListener = null;
+        Consumer<Integer> pointerCaptureStateCallback = null;
+
+        Executor executor = null;
+        ReformOptions reformOptions;
+        Executor transformExecutor = null;
+
+        private FakeNode() {}
+
+        @Override
+        public void listenForInput(
+                @NonNull Consumer<InputEvent> listener, @NonNull Executor executor) {
+            this.listener = listener;
+            this.executor = executor;
+        }
+
+        @Override
+        public void stopListeningForInput() {
+            listener = null;
+            executor = null;
+        }
+
+        @Override
+        public void setNonPointerFocusTarget(@NonNull AttachedSurfaceControl focusTarget) {}
+
+        @Override
+        public void requestPointerCapture(
+                @NonNull Consumer<Integer> stateCallback, @NonNull Executor executor) {
+            pointerCaptureStateCallback = stateCallback;
+        }
+
+        @Override
+        public void stopPointerCapture() {
+            pointerCaptureStateCallback = null;
+        }
+
+        public void sendInputEvent(@NonNull InputEvent event) {
+            executor.execute(() -> listener.accept(event));
+        }
+
+        public void sendTransformEvent(@NonNull FakeNodeTransform nodeTransform) {
+            transformExecutor.execute(() -> transformListener.accept(nodeTransform));
+        }
+
+        @Override
+        @NonNull
+        public Closeable subscribeToTransform(
+                @NonNull Consumer<NodeTransform> transformCallback, @NonNull Executor executor) {
+            this.transformListener = transformCallback;
+            this.transformExecutor = executor;
+            return new FakeCloseable();
+        }
+
+        @Nullable
+        public Consumer<NodeTransform> getTransformListener() {
+            return transformListener;
+        }
+
+        @Nullable
+        public Executor getTransformExecutor() {
+            return transformExecutor;
+        }
+
+        @Override
+        public void writeToParcel(@NonNull Parcel in, int flags) {}
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Nullable
+        public FakeNode getParent() {
+            return parent;
+        }
+
+        public float getXPosition() {
+            return xPosition;
+        }
+
+        public float getYPosition() {
+            return yPosition;
+        }
+
+        public float getZPosition() {
+            return zPosition;
+        }
+
+        public float getXOrientation() {
+            return xOrientation;
+        }
+
+        public float getYOrientation() {
+            return yOrientation;
+        }
+
+        public float getZOrientation() {
+            return zOrientation;
+        }
+
+        public float getWOrientation() {
+            return wOrientation;
+        }
+
+        public boolean isVisible() {
+            return isVisible;
+        }
+
+        public float getAlpha() {
+            return alpha;
+        }
+
+        /**
+         * @deprecated This method is no longer supported.
+         */
+        @Nullable
+        @Deprecated
+        public androidx.xr.extensions.asset.GltfModelToken getGltfModel() {
+            return gltfModel;
+        }
+
+        @Nullable
+        public IBinder getAnchorId() {
+            return anchorId;
+        }
+
+        @Nullable
+        public String getName() {
+            return name;
+        }
+
+        @Nullable
+        public SurfaceControlViewHost.SurfacePackage getSurfacePackage() {
+            return surfacePackage;
+        }
+
+        /**
+         * @deprecated This method is no longer supported.
+         */
+        @Nullable
+        @Deprecated
+        public androidx.xr.extensions.asset.EnvironmentToken getEnvironment() {
+            return environment;
+        }
+
+        @Nullable
+        public Consumer<InputEvent> getListener() {
+            return listener;
+        }
+
+        @Nullable
+        public Consumer<Integer> getPointerCaptureStateCallback() {
+            return pointerCaptureStateCallback;
+        }
+
+        @Nullable
+        public Executor getExecutor() {
+            return executor;
+        }
+
+        @Nullable
+        public ReformOptions getReformOptions() {
+            return reformOptions;
+        }
+    }
+
+    /**
+     * A fake implementation of the XR extensions Node transaction.
+     *
+     * <p>All modifications happen immediately and not when the transaction is applied.
+     */
+    @SuppressWarnings("NotCloseable")
+    public static class FakeNodeTransaction implements NodeTransaction {
+        FakeNode lastFakeNode = null;
+        boolean applied = false;
+
+        private FakeNodeTransaction() {}
+
+        @Override
+        @NonNull
+        public NodeTransaction setParent(@NonNull Node node, @Nullable Node parent) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).parent = (FakeNode) parent;
+            return this;
+        }
+
+        /**
+         * @deprecated This method is no longer supported.
+         */
+        @Override
+        @NonNull
+        @Deprecated
+        public NodeTransaction setEnvironment(
+                @NonNull Node node, @Nullable androidx.xr.extensions.asset.EnvironmentToken token) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).environment = token;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setPosition(@NonNull Node node, float x, float y, float z) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).xPosition = x;
+            ((FakeNode) node).yPosition = y;
+            ((FakeNode) node).zPosition = z;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setOrientation(
+                @NonNull Node node, float x, float y, float z, float w) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).xOrientation = x;
+            ((FakeNode) node).yOrientation = y;
+            ((FakeNode) node).zOrientation = z;
+            ((FakeNode) node).wOrientation = w;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        // TODO(b/354731545): Cover this with an AndroidXREntity test
+        public NodeTransaction setScale(@NonNull Node node, float sx, float sy, float sz) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).xScale = sx;
+            ((FakeNode) node).yScale = sy;
+            ((FakeNode) node).zScale = sz;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setVisibility(@NonNull Node node, boolean isVisible) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).isVisible = isVisible;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setAlpha(@NonNull Node node, float value) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).alpha = value;
+            return this;
+        }
+
+        /**
+         * @deprecated This method is no longer supported.
+         */
+        @Override
+        @NonNull
+        @Deprecated
+        public NodeTransaction setGltfModel(
+                @NonNull Node node,
+                @NonNull androidx.xr.extensions.asset.GltfModelToken gltfModelToken) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).gltfModel = gltfModelToken;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setName(@NonNull Node node, @NonNull String name) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).name = name;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setPassthroughState(
+                @NonNull Node node,
+                float passthroughOpacity,
+                @PassthroughState.Mode int passthroughMode) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).passthroughOpacity = passthroughOpacity;
+            ((FakeNode) node).passthroughMode = passthroughMode;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setSurfacePackage(
+                @Nullable Node node,
+                @NonNull SurfaceControlViewHost.SurfacePackage surfacePackage) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).surfacePackage = surfacePackage;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setWindowBounds(
+                @NonNull SurfaceControlViewHost.SurfacePackage surfacePackage,
+                int widthPx,
+                int heightPx) {
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setAnchorId(@NonNull Node node, @Nullable IBinder anchorId) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).anchorId = anchorId;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction enableReform(@NonNull Node node, @NonNull ReformOptions options) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).reformOptions = options;
+            ((FakeReformOptions) options).optionsApplied = true;
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setReformSize(@NonNull Node node, @NonNull Vec3 reformSize) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).reformOptions.setCurrentSize(reformSize);
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction disableReform(@NonNull Node node) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).reformOptions = new FakeReformOptions(null, null);
+            return this;
+        }
+
+        @Override
+        @NonNull
+        public NodeTransaction setCornerRadius(@NonNull Node node, float cornerRadius) {
+            lastFakeNode = (FakeNode) node;
+            ((FakeNode) node).cornerRadius = cornerRadius;
+            return this;
+        }
+
+        @Override
+        public void apply() {
+            applied = true;
+        }
+
+        @Override
+        public void close() {}
+    }
+
+    /** A fake implementation of the XR extensions NodeTransform. */
+    public static class FakeNodeTransform implements NodeTransform {
+        Mat4f transform;
+
+        public FakeNodeTransform(@NonNull Mat4f transform) {
+            this.transform = transform;
+        }
+
+        @Override
+        @NonNull
+        public Mat4f getTransform() {
+            return transform;
+        }
+
+        @Override
+        public long getTimestamp() {
+            return 0;
+        }
+    }
+
+    /** A fake implementation of the XR extensions GltfModelToken. */
+    public static class FakeGltfModelToken implements androidx.xr.extensions.asset.GltfModelToken {
+        String url;
+
+        public FakeGltfModelToken(@NonNull String url) {
+            this.url = url;
+        }
+
+        @NonNull
+        public String getUrl() {
+            return url;
+        }
+    }
+
+    /** A fake implementation of the XR extensions EnvironmentToken. */
+    public static class FakeEnvironmentToken
+            implements androidx.xr.extensions.asset.EnvironmentToken {
+        String url;
+
+        private FakeEnvironmentToken(@NonNull String url) {
+            this.url = url;
+        }
+
+        @NonNull
+        public String getUrl() {
+            return url;
+        }
+    }
+
+    /** A fake implementation of the XR extensions EnvironmentVisibilityState. */
+    public static class FakeEnvironmentVisibilityState implements EnvironmentVisibilityState {
+        @EnvironmentVisibilityState.State int state;
+
+        public FakeEnvironmentVisibilityState(@EnvironmentVisibilityState.State int state) {
+            this.state = state;
+        }
+
+        @Override
+        @EnvironmentVisibilityState.State
+        public int getCurrentState() {
+            return state;
+        }
+    }
+
+    /** A fake implementation of the XR extensions EnvironmentVisibilityState. */
+    public static class FakePassthroughVisibilityState implements PassthroughVisibilityState {
+        @PassthroughVisibilityState.State int state;
+        float opacity;
+
+        public FakePassthroughVisibilityState(
+                @PassthroughVisibilityState.State int state, float opacity) {
+            this.state = state;
+            this.opacity = opacity;
+        }
+
+        @Override
+        @PassthroughVisibilityState.State
+        public int getCurrentState() {
+            return state;
+        }
+
+        @Override
+        public float getOpacity() {
+            return opacity;
+        }
+    }
+
+    /** Creates fake activity panel. */
+    public static class FakeActivityPanel implements ActivityPanel {
+        Intent launchIntent;
+        Bundle bundle;
+        Activity activity;
+        Rect bounds;
+        boolean isDeleted = false;
+        Node node;
+
+        FakeActivityPanel(@NonNull Node node) {
+            this.node = node;
+        }
+
+        @Override
+        public void launchActivity(@NonNull Intent intent, @Nullable Bundle options) {
+            launchIntent = intent;
+            this.bundle = options;
+        }
+
+        @Nullable
+        public Intent getLaunchIntent() {
+            return launchIntent;
+        }
+
+        @NonNull
+        public Bundle getBundle() {
+            return bundle;
+        }
+
+        @Override
+        public void moveActivity(@NonNull Activity activity) {
+            this.activity = activity;
+        }
+
+        @Nullable
+        public Activity getActivity() {
+            return activity;
+        }
+
+        @NonNull
+        @Override
+        public Node getNode() {
+            return node;
+        }
+
+        @Override
+        public void setWindowBounds(@NonNull Rect windowBounds) {
+            bounds = windowBounds;
+        }
+
+        @Nullable
+        public Rect getBounds() {
+            return bounds;
+        }
+
+        @Override
+        public void delete() {
+            isDeleted = true;
+        }
+
+        public boolean isDeleted() {
+            return isDeleted;
+        }
+    }
+
+    /** Fake input event. */
+    public static class FakeInputEvent implements InputEvent {
+        int source;
+        int pointerType;
+        long timestamp;
+        Vec3 origin;
+        Vec3 direction;
+        FakeHitInfo hitInfo;
+        FakeHitInfo secondaryHitInfo;
+        int dispatchFlags;
+        int action;
+
+        @Override
+        public int getSource() {
+            return source;
+        }
+
+        @Override
+        public int getPointerType() {
+            return pointerType;
+        }
+
+        @Override
+        public long getTimestamp() {
+            return timestamp;
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getOrigin() {
+            return origin;
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getDirection() {
+            return direction;
+        }
+
+        @Override
+        @Nullable
+        public HitInfo getHitInfo() {
+            return hitInfo;
+        }
+
+        @Override
+        @Nullable
+        public HitInfo getSecondaryHitInfo() {
+            return secondaryHitInfo;
+        }
+
+        @Override
+        public int getDispatchFlags() {
+            return dispatchFlags;
+        }
+
+        @Override
+        public int getAction() {
+            return action;
+        }
+
+        public void setDispatchFlags(int dispatchFlags) {
+            this.dispatchFlags = dispatchFlags;
+        }
+
+        public void setOrigin(@NonNull Vec3 origin) {
+            this.origin = origin;
+        }
+
+        public void setDirection(@NonNull Vec3 direction) {
+            this.direction = direction;
+        }
+
+        public void setFakeHitInfo(@NonNull FakeHitInfo hitInfo) {
+            this.hitInfo = hitInfo;
+        }
+
+        public void setTimestamp(long timestamp) {
+            this.timestamp = timestamp;
+        }
+
+        /** Fake hit info. */
+        public static class FakeHitInfo implements InputEvent.HitInfo {
+            int subspaceImpressNodeId;
+            Node inputNode;
+            Vec3 hitPosition;
+            Mat4f transform;
+
+            @Override
+            public int getSubspaceImpressNodeId() {
+                return subspaceImpressNodeId;
+            }
+
+            @Override
+            @NonNull
+            public Node getInputNode() {
+                return inputNode;
+            }
+
+            @Override
+            @Nullable
+            public Vec3 getHitPosition() {
+                return hitPosition;
+            }
+
+            @Override
+            @NonNull
+            public Mat4f getTransform() {
+                return transform;
+            }
+
+            public void setSubspaceImpressNodeId(int subspaceImpressNodeId) {
+                this.subspaceImpressNodeId = subspaceImpressNodeId;
+            }
+
+            public void setInputNode(@NonNull Node inputNode) {
+                this.inputNode = inputNode;
+            }
+
+            public void setHitPosition(@Nullable Vec3 hitPosition) {
+                this.hitPosition = hitPosition;
+            }
+
+            public void setTransform(@NonNull Mat4f transform) {
+                this.transform = transform;
+            }
+        }
+    }
+
+    /** Fake ReformOptions. */
+    public static class FakeReformOptions implements ReformOptions {
+
+        int enabledReforms;
+        int reformFlags;
+        int scaleWithDistanceMode = SCALE_WITH_DISTANCE_MODE_DEFAULT;
+        Vec3 currentSize;
+        Vec3 minimumSize;
+        Vec3 maximumSize;
+        float fixedAspectRatio;
+        Consumer<ReformEvent> consumer;
+        Executor executor;
+
+        boolean optionsApplied = true;
+
+        FakeReformOptions(Consumer<ReformEvent> consumer, Executor executor) {
+            this.consumer = consumer;
+            this.executor = executor;
+        }
+
+        @Override
+        public int getEnabledReform() {
+            return enabledReforms;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setEnabledReform(int i) {
+            enabledReforms = i;
+            return this;
+        }
+
+        @Override
+        public int getFlags() {
+            return reformFlags;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setFlags(int i) {
+            optionsApplied = false;
+            reformFlags = i;
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getCurrentSize() {
+            return currentSize;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setCurrentSize(@NonNull Vec3 vec3) {
+            optionsApplied = false;
+            currentSize = vec3;
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getMinimumSize() {
+            return minimumSize;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setMinimumSize(@NonNull Vec3 vec3) {
+            optionsApplied = false;
+            minimumSize = vec3;
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getMaximumSize() {
+            return maximumSize;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setMaximumSize(@NonNull Vec3 vec3) {
+            optionsApplied = false;
+            maximumSize = vec3;
+            return this;
+        }
+
+        @Override
+        public float getFixedAspectRatio() {
+            return fixedAspectRatio;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setFixedAspectRatio(float fixedAspectRatio) {
+            this.fixedAspectRatio = fixedAspectRatio;
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Consumer<ReformEvent> getEventCallback() {
+            return consumer;
+        }
+
+        @Override
+        @NonNull
+        @SuppressLint("InvalidNullabilityOverride")
+        public ReformOptions setEventCallback(@NonNull Consumer<ReformEvent> consumer) {
+            this.consumer = consumer;
+            return this;
+        }
+
+        @NonNull
+        @Override
+        public Executor getEventExecutor() {
+            return executor;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setEventExecutor(@NonNull Executor executor) {
+            this.executor = executor;
+            return this;
+        }
+
+        @Override
+        public int getScaleWithDistanceMode() {
+            return scaleWithDistanceMode;
+        }
+
+        @Override
+        @NonNull
+        public ReformOptions setScaleWithDistanceMode(int scaleWithDistanceMode) {
+            this.scaleWithDistanceMode = scaleWithDistanceMode;
+            return this;
+        }
+    }
+
+    /** Fake ReformEvent. */
+    public static class FakeReformEvent implements ReformEvent {
+
+        int type;
+        int state;
+        int id;
+        Vec3 origin = new Vec3(0f, 0f, 0f);
+        Vec3 initialRayOrigin = origin;
+        Vec3 proposedPosition = origin;
+        Vec3 ones = new Vec3(1f, 1f, 1f);
+        Vec3 initialRayDirection = ones;
+        Vec3 proposedScale = ones;
+        Vec3 proposedSize = ones;
+        Vec3 twos = new Vec3(2f, 2f, 2f);
+        Vec3 currentRayOrigin = twos;
+        Vec3 threes = new Vec3(3f, 3f, 3f);
+        Vec3 currentRayDirection = threes;
+        Quatf identity = new Quatf(0f, 0f, 0f, 1f);
+        Quatf proposedOrientation = identity;
+
+        public void setType(int type) {
+            this.type = type;
+        }
+
+        public void setState(int state) {
+            this.state = state;
+        }
+
+        public void setProposedPosition(@NonNull Vec3 proposedPosition) {
+            this.proposedPosition = proposedPosition;
+        }
+
+        public void setProposedScale(@NonNull Vec3 proposedScale) {
+            this.proposedScale = proposedScale;
+        }
+
+        public void setProposedOrientation(@NonNull Quatf proposedOrientation) {
+            this.proposedOrientation = proposedOrientation;
+        }
+
+        @Override
+        public int getType() {
+            return type;
+        }
+
+        @Override
+        public int getState() {
+            return state;
+        }
+
+        @Override
+        public int getId() {
+            return id;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getInitialRayOrigin() {
+            return initialRayOrigin;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getInitialRayDirection() {
+            return initialRayDirection;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getCurrentRayOrigin() {
+            return currentRayOrigin;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getCurrentRayDirection() {
+            return currentRayDirection;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getProposedPosition() {
+            return proposedPosition;
+        }
+
+        @NonNull
+        @Override
+        public Quatf getProposedOrientation() {
+            return proposedOrientation;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getProposedScale() {
+            return proposedScale;
+        }
+
+        @NonNull
+        @Override
+        public Vec3 getProposedSize() {
+            return proposedSize;
+        }
+    }
+
+    /** A fake implementation of the XR extensions SpatialState. */
+    public static class FakeSpatialState implements SpatialState {
+        Bounds bounds;
+        SpatialCapabilities capabilities;
+        EnvironmentVisibilityState environmentVisibilityState;
+        PassthroughVisibilityState passthroughVisibilityState;
+
+        public FakeSpatialState() {
+            // Initialize params to any non-null values
+            // TODO: b/370033054 - Revisit the default values for the bounds and capabilities.
+            this.bounds =
+                    new Bounds(
+                            Float.POSITIVE_INFINITY,
+                            Float.POSITIVE_INFINITY,
+                            Float.POSITIVE_INFINITY);
+            this.setAllSpatialCapabilities(true);
+            this.environmentVisibilityState =
+                    new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.INVISIBLE);
+            this.passthroughVisibilityState =
+                    new FakePassthroughVisibilityState(PassthroughVisibilityState.DISABLED, 0.0f);
+        }
+
+        @Override
+        @NonNull
+        public Bounds getBounds() {
+            return bounds;
+        }
+
+        public void setBounds(@NonNull Bounds bounds) {
+            this.bounds = bounds;
+        }
+
+        @Override
+        @NonNull
+        public SpatialCapabilities getSpatialCapabilities() {
+            return capabilities;
+        }
+
+        @Override
+        @NonNull
+        public EnvironmentVisibilityState getEnvironmentVisibility() {
+            return environmentVisibilityState;
+        }
+
+        public void setEnvironmentVisibility(
+                @NonNull EnvironmentVisibilityState environmentVisibilityState) {
+            this.environmentVisibilityState = environmentVisibilityState;
+        }
+
+        @Override
+        @NonNull
+        public PassthroughVisibilityState getPassthroughVisibility() {
+            return passthroughVisibilityState;
+        }
+
+        public void setPassthroughVisibility(
+                @NonNull PassthroughVisibilityState passthroughVisibilityState) {
+            this.passthroughVisibilityState = passthroughVisibilityState;
+        }
+
+        // Methods for tests to set the capabilities.
+        public void setSpatialCapabilities(@NonNull SpatialCapabilities capabilities) {
+            this.capabilities = capabilities;
+        }
+
+        public void setAllSpatialCapabilities(boolean allowAll) {
+            this.capabilities =
+                    new SpatialCapabilities() {
+                        @Override
+                        public boolean get(int capQuery) {
+                            return allowAll;
+                        }
+                    };
+        }
+    }
+
+    /** Fake XrSpatialAudioExtensions. */
+    public static class FakeSpatialAudioExtensions implements XrSpatialAudioExtensions {
+
+        @NonNull
+        public final FakeSoundPoolExtensions soundPoolExtensions = new FakeSoundPoolExtensions();
+
+        FakeAudioTrackExtensions audioTrackExtensions = new FakeAudioTrackExtensions();
+
+        @NonNull
+        public final FakeMediaPlayerExtensions mediaPlayerExtensions =
+                new FakeMediaPlayerExtensions();
+
+        @NonNull
+        @Override
+        public SoundPoolExtensions getSoundPoolExtensions() {
+            return soundPoolExtensions;
+        }
+
+        @NonNull
+        @Override
+        public AudioTrackExtensions getAudioTrackExtensions() {
+            return audioTrackExtensions;
+        }
+
+        public void setFakeAudioTrackExtensions(
+                @NonNull FakeAudioTrackExtensions audioTrackExtensions) {
+            this.audioTrackExtensions = audioTrackExtensions;
+        }
+
+        @NonNull
+        @Override
+        public MediaPlayerExtensions getMediaPlayerExtensions() {
+            return mediaPlayerExtensions;
+        }
+    }
+
+    /** Fake SoundPoolExtensions. */
+    public static class FakeSoundPoolExtensions implements SoundPoolExtensions {
+
+        int playAsPointSourceResult = 0;
+        int playAsSoundFieldResult = 0;
+        int sourceType = SpatializerExtensions.SOURCE_TYPE_BYPASS;
+
+        public void setPlayAsPointSourceResult(int result) {
+            playAsPointSourceResult = result;
+        }
+
+        public void setPlayAsSoundFieldResult(int result) {
+            playAsSoundFieldResult = result;
+        }
+
+        public void setSourceType(@SpatializerExtensions.SourceType int sourceType) {
+            this.sourceType = sourceType;
+        }
+
+        @Override
+        public int playAsPointSource(
+                @NonNull SoundPool soundPool,
+                int soundID,
+                @NonNull PointSourceAttributes attributes,
+                float volume,
+                int priority,
+                int loop,
+                float rate) {
+            return playAsPointSourceResult;
+        }
+
+        @Override
+        public int playAsSoundField(
+                @NonNull SoundPool soundPool,
+                int soundID,
+                @NonNull SoundFieldAttributes attributes,
+                float volume,
+                int priority,
+                int loop,
+                float rate) {
+            return playAsSoundFieldResult;
+        }
+
+        @Override
+        @SpatializerExtensions.SourceType
+        public int getSpatialSourceType(@NonNull SoundPool soundPool, int streamID) {
+            return sourceType;
+        }
+    }
+
+    /** Fake AudioTrackExtensions. */
+    public static class FakeAudioTrackExtensions implements AudioTrackExtensions {
+
+        PointSourceAttributes pointSourceAttributes;
+
+        SoundFieldAttributes soundFieldAttributes;
+
+        @SpatializerExtensions.SourceType int sourceType;
+
+        @CanIgnoreReturnValue
+        @Override
+        @NonNull
+        public AudioTrack.Builder setPointSourceAttributes(
+                @NonNull AudioTrack.Builder builder, @Nullable PointSourceAttributes attributes) {
+            this.pointSourceAttributes = attributes;
+            return builder;
+        }
+
+        public void setPointSourceAttributes(
+                @Nullable PointSourceAttributes pointSourceAttributes) {
+            this.pointSourceAttributes = pointSourceAttributes;
+        }
+
+        @CanIgnoreReturnValue
+        @Override
+        @NonNull
+        public AudioTrack.Builder setSoundFieldAttributes(
+                @NonNull AudioTrack.Builder builder, @Nullable SoundFieldAttributes attributes) {
+            this.soundFieldAttributes = attributes;
+            return builder;
+        }
+
+        public void setSoundFieldAttributes(@Nullable SoundFieldAttributes soundFieldAttributes) {
+            this.soundFieldAttributes = soundFieldAttributes;
+        }
+
+        @Override
+        @Nullable
+        public PointSourceAttributes getPointSourceAttributes(@NonNull AudioTrack track) {
+            return pointSourceAttributes;
+        }
+
+        @Nullable
+        public PointSourceAttributes getPointSourceAttributes() {
+            return pointSourceAttributes;
+        }
+
+        @Override
+        @Nullable
+        public SoundFieldAttributes getSoundFieldAttributes(@NonNull AudioTrack track) {
+            return soundFieldAttributes;
+        }
+
+        @Nullable
+        public SoundFieldAttributes getSoundFieldAttributes() {
+            return soundFieldAttributes;
+        }
+
+        @Override
+        public int getSpatialSourceType(@NonNull AudioTrack track) {
+            return sourceType;
+        }
+
+        public void setSourceType(@SpatializerExtensions.SourceType int sourceType) {
+            this.sourceType = sourceType;
+        }
+    }
+
+    /** Fake MediaPlayerExtensions. */
+    public static class FakeMediaPlayerExtensions implements MediaPlayerExtensions {
+
+        PointSourceAttributes pointSourceAttributes;
+
+        SoundFieldAttributes soundFieldAttributes;
+
+        @CanIgnoreReturnValue
+        @Override
+        @NonNull
+        public MediaPlayer setPointSourceAttributes(
+                @NonNull MediaPlayer mediaPlayer, @Nullable PointSourceAttributes attributes) {
+            this.pointSourceAttributes = attributes;
+            return mediaPlayer;
+        }
+
+        @CanIgnoreReturnValue
+        @Override
+        @NonNull
+        public MediaPlayer setSoundFieldAttributes(
+                @NonNull MediaPlayer mediaPlayer, @Nullable SoundFieldAttributes attributes) {
+            this.soundFieldAttributes = attributes;
+            return mediaPlayer;
+        }
+
+        @Nullable
+        public PointSourceAttributes getPointSourceAttributes() {
+            return pointSourceAttributes;
+        }
+
+        @Nullable
+        public SoundFieldAttributes getSoundFieldAttributes() {
+            return soundFieldAttributes;
+        }
+    }
+}
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/scenecore/scenecore/api/current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/current.txt
copy to xr/scenecore/scenecore/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/scenecore/scenecore/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/scenecore/scenecore/api/res-current.txt
diff --git a/xr/scenecore/scenecore/api/restricted_current.txt b/xr/scenecore/scenecore/api/restricted_current.txt
new file mode 100644
index 0000000..762db0b
--- /dev/null
+++ b/xr/scenecore/scenecore/api/restricted_current.txt
@@ -0,0 +1,1879 @@
+// Signature format: 4.0
+package androidx.xr.extensions {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Config {
+    method public float defaultPixelsPerMeter(float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.FunctionalInterface public interface Consumer<T> {
+    method public void accept(T!);
+  }
+
+  @SuppressCompatibility @RequiresOptIn(level=androidx.annotation.RequiresOptIn.Level.ERROR) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.CLASS) @java.lang.annotation.Target({java.lang.annotation.ElementType.TYPE, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.CONSTRUCTOR, java.lang.annotation.ElementType.FIELD}) public @interface ExperimentalExtensionApi {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class IBinderWrapper {
+    ctor public IBinderWrapper(android.os.IBinder);
+    method protected android.os.IBinder getRawToken();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface XrExtensionResult {
+    method @androidx.xr.extensions.XrExtensionResult.ResultType public default int getResult();
+    field @Deprecated public static final int XR_RESULT_ERROR_IGNORED = 3; // 0x3
+    field @Deprecated public static final int XR_RESULT_ERROR_INVALID_STATE = 2; // 0x2
+    field public static final int XR_RESULT_ERROR_NOT_ALLOWED = 3; // 0x3
+    field public static final int XR_RESULT_ERROR_SYSTEM = 4; // 0x4
+    field public static final int XR_RESULT_IGNORED_ALREADY_APPLIED = 2; // 0x2
+    field public static final int XR_RESULT_SUCCESS = 0; // 0x0
+    field public static final int XR_RESULT_SUCCESS_NOT_VISIBLE = 1; // 0x1
+  }
+
+  @IntDef({androidx.xr.extensions.XrExtensionResult.XR_RESULT_SUCCESS, androidx.xr.extensions.XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE, androidx.xr.extensions.XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED, androidx.xr.extensions.XrExtensionResult.XR_RESULT_ERROR_INVALID_STATE, androidx.xr.extensions.XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED, androidx.xr.extensions.XrExtensionResult.XR_RESULT_ERROR_IGNORED, androidx.xr.extensions.XrExtensionResult.XR_RESULT_ERROR_SYSTEM}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface XrExtensionResult.ResultType {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface XrExtensions {
+    method public void addFindableView(android.view.View, android.view.ViewGroup);
+    method @Deprecated public void attachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.node.Node);
+    method public void attachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void attachSpatialScene(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node);
+    method public void attachSpatialScene(android.app.Activity, androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public boolean canEmbedActivityPanel(android.app.Activity);
+    method public void clearSpatialStateCallback(android.app.Activity);
+    method public androidx.xr.extensions.space.ActivityPanel createActivityPanel(android.app.Activity, androidx.xr.extensions.space.ActivityPanelLaunchParameters);
+    method public androidx.xr.extensions.node.Node createNode();
+    method public androidx.xr.extensions.node.NodeTransaction createNodeTransaction();
+    method public androidx.xr.extensions.node.ReformOptions createReformOptions(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.splitengine.SplitEngineBridge createSplitEngineBridge();
+    method public androidx.xr.extensions.subspace.Subspace createSubspace(androidx.xr.extensions.splitengine.SplitEngineBridge, int);
+    method @Deprecated public void detachSpatialEnvironment(android.app.Activity);
+    method public void detachSpatialEnvironment(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void detachSpatialScene(android.app.Activity);
+    method public void detachSpatialScene(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.XrExtensions.SceneViewerResult!> displayGltfModel(android.app.Activity!, androidx.xr.extensions.asset.GltfModelToken!);
+    method public int getApiVersion();
+    method @Deprecated public void getBounds(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.Bounds!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.Config getConfig();
+    method public int getOpenXrWorldSpaceType();
+    method @Deprecated public void getSpatialCapabilities(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialCapabilities!>, java.util.concurrent.Executor);
+    method public androidx.xr.extensions.space.SpatialState getSpatialState(android.app.Activity);
+    method public androidx.xr.extensions.node.Node? getSurfaceTrackingNode(android.view.View);
+    method public androidx.xr.extensions.media.XrSpatialAudioExtensions getXrSpatialAudioExtensions();
+    method public void hitTest(android.app.Activity, androidx.xr.extensions.node.Vec3, androidx.xr.extensions.node.Vec3, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.HitTestResult!>, java.util.concurrent.Executor);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.EnvironmentToken!> loadEnvironment(java.io.InputStream!, int, int, String!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.EnvironmentToken!> loadEnvironment(java.io.InputStream!, int, int, String!, int, int);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.GltfModelToken!> loadGltfModel(java.io.InputStream!, int, int, String!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<androidx.xr.extensions.asset.SceneToken!> loadImpressScene(java.io.InputStream!, int, int);
+    method public void registerSpatialStateCallback(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialState!>, java.util.concurrent.Executor);
+    method public void removeFindableView(android.view.View, android.view.ViewGroup);
+    method @Deprecated public boolean requestFullSpaceMode(android.app.Activity);
+    method public void requestFullSpaceMode(android.app.Activity, boolean, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public boolean requestHomeSpaceMode(android.app.Activity);
+    method public android.os.Bundle setFullSpaceMode(android.os.Bundle);
+    method public android.os.Bundle setFullSpaceModeWithEnvironmentInherited(android.os.Bundle);
+    method @Deprecated public android.os.Bundle setMainPanelCurvatureRadius(android.os.Bundle, float);
+    method @Deprecated public void setMainWindowCurvatureRadius(android.app.Activity, float);
+    method @Deprecated public void setMainWindowSize(android.app.Activity, int, int);
+    method public void setMainWindowSize(android.app.Activity, int, int, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void setPreferredAspectRatio(android.app.Activity, float);
+    method public void setPreferredAspectRatio(android.app.Activity, float, androidx.xr.extensions.Consumer<androidx.xr.extensions.XrExtensionResult!>, java.util.concurrent.Executor);
+    method @Deprecated public void setSpatialStateCallback(android.app.Activity, androidx.xr.extensions.Consumer<androidx.xr.extensions.space.SpatialStateEvent!>, java.util.concurrent.Executor);
+    field public static final String IMAGE_TOO_OLD = "This device\'s system image doesn\'t include the necessary implementation for this API. Please update to the latest system image. This API requires a corresponding implementation on the device to function correctly.";
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static class XrExtensions.SceneViewerResult {
+    ctor @Deprecated public XrExtensions.SceneViewerResult();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class XrExtensionsProvider {
+    method public static androidx.xr.extensions.XrExtensions? getXrExtensions();
+  }
+
+}
+
+package androidx.xr.extensions.asset {
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface EnvironmentToken extends androidx.xr.extensions.asset.AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface GltfAnimation {
+  }
+
+  @Deprecated public enum GltfAnimation.State {
+    enum_constant @Deprecated public static final androidx.xr.extensions.asset.GltfAnimation.State LOOP;
+    enum_constant @Deprecated public static final androidx.xr.extensions.asset.GltfAnimation.State PLAY;
+    enum_constant @Deprecated public static final androidx.xr.extensions.asset.GltfAnimation.State STOP;
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface GltfModelToken extends androidx.xr.extensions.asset.AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SceneToken extends androidx.xr.extensions.asset.AssetToken {
+  }
+
+}
+
+package androidx.xr.extensions.environment {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface EnvironmentVisibilityState {
+    method @androidx.xr.extensions.environment.EnvironmentVisibilityState.State public default int getCurrentState();
+    field public static final int APP_VISIBLE = 2; // 0x2
+    field public static final int HOME_VISIBLE = 1; // 0x1
+    field public static final int INVISIBLE = 0; // 0x0
+  }
+
+  @IntDef({androidx.xr.extensions.environment.EnvironmentVisibilityState.INVISIBLE, androidx.xr.extensions.environment.EnvironmentVisibilityState.HOME_VISIBLE, androidx.xr.extensions.environment.EnvironmentVisibilityState.APP_VISIBLE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface EnvironmentVisibilityState.State {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface PassthroughVisibilityState {
+    method @androidx.xr.extensions.environment.PassthroughVisibilityState.State public default int getCurrentState();
+    method public default float getOpacity();
+    field public static final int APP = 2; // 0x2
+    field public static final int DISABLED = 0; // 0x0
+    field public static final int HOME = 1; // 0x1
+    field public static final int SYSTEM = 3; // 0x3
+  }
+
+  @IntDef({androidx.xr.extensions.environment.PassthroughVisibilityState.DISABLED, androidx.xr.extensions.environment.PassthroughVisibilityState.HOME, androidx.xr.extensions.environment.PassthroughVisibilityState.APP, androidx.xr.extensions.environment.PassthroughVisibilityState.SYSTEM}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface PassthroughVisibilityState.State {
+  }
+
+}
+
+package androidx.xr.extensions.media {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface AudioManagerExtensions {
+    method public default void playSoundEffectAsPointSource(android.media.AudioManager, int, androidx.xr.extensions.media.PointSourceAttributes);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface AudioTrackExtensions {
+    method public default androidx.xr.extensions.media.PointSourceAttributes? getPointSourceAttributes(android.media.AudioTrack);
+    method public default androidx.xr.extensions.media.SoundFieldAttributes? getSoundFieldAttributes(android.media.AudioTrack);
+    method @androidx.xr.extensions.media.SpatializerExtensions.SourceType public default int getSpatialSourceType(android.media.AudioTrack);
+    method public default android.media.AudioTrack.Builder setPointSourceAttributes(android.media.AudioTrack.Builder, androidx.xr.extensions.media.PointSourceAttributes);
+    method public default android.media.AudioTrack.Builder setSoundFieldAttributes(android.media.AudioTrack.Builder, androidx.xr.extensions.media.SoundFieldAttributes);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface MediaPlayerExtensions {
+    method public default android.media.MediaPlayer setPointSourceAttributes(android.media.MediaPlayer, androidx.xr.extensions.media.PointSourceAttributes);
+    method public default android.media.MediaPlayer setSoundFieldAttributes(android.media.MediaPlayer, androidx.xr.extensions.media.SoundFieldAttributes);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PointSourceAttributes {
+    method public androidx.xr.extensions.node.Node getNode();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final class PointSourceAttributes.Builder {
+    ctor public PointSourceAttributes.Builder();
+    method public androidx.xr.extensions.media.PointSourceAttributes build() throws java.lang.UnsupportedOperationException;
+    method public androidx.xr.extensions.media.PointSourceAttributes.Builder setNode(androidx.xr.extensions.node.Node);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SoundFieldAttributes {
+    method @androidx.xr.extensions.media.SpatializerExtensions.AmbisonicsOrder public int getAmbisonicsOrder();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final class SoundFieldAttributes.Builder {
+    ctor public SoundFieldAttributes.Builder();
+    method public androidx.xr.extensions.media.SoundFieldAttributes build() throws java.lang.UnsupportedOperationException;
+    method public androidx.xr.extensions.media.SoundFieldAttributes.Builder setAmbisonicsOrder(@androidx.xr.extensions.media.SpatializerExtensions.AmbisonicsOrder int);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SoundPoolExtensions {
+    method @androidx.xr.extensions.media.SpatializerExtensions.SourceType public default int getSpatialSourceType(android.media.SoundPool, int);
+    method public default int playAsPointSource(android.media.SoundPool, int, androidx.xr.extensions.media.PointSourceAttributes, float, int, int, float);
+    method public default int playAsSoundField(android.media.SoundPool, int, androidx.xr.extensions.media.SoundFieldAttributes, float, int, int, float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatializerExtensions {
+    field public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+    field public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+    field public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+    field public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+    field public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+    field public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+  }
+
+  @IntDef({androidx.xr.extensions.media.SpatializerExtensions.AMBISONICS_ORDER_FIRST_ORDER, androidx.xr.extensions.media.SpatializerExtensions.AMBISONICS_ORDER_SECOND_ORDER, androidx.xr.extensions.media.SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface SpatializerExtensions.AmbisonicsOrder {
+  }
+
+  @IntDef({androidx.xr.extensions.media.SpatializerExtensions.SOURCE_TYPE_BYPASS, androidx.xr.extensions.media.SpatializerExtensions.SOURCE_TYPE_POINT_SOURCE, androidx.xr.extensions.media.SpatializerExtensions.SOURCE_TYPE_SOUND_FIELD}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface SpatializerExtensions.SourceType {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface XrSpatialAudioExtensions {
+    method public default androidx.xr.extensions.media.AudioManagerExtensions getAudioManagerExtensions();
+    method public default androidx.xr.extensions.media.AudioTrackExtensions getAudioTrackExtensions();
+    method public default androidx.xr.extensions.media.MediaPlayerExtensions getMediaPlayerExtensions();
+    method public default androidx.xr.extensions.media.SoundPoolExtensions getSoundPoolExtensions();
+  }
+
+}
+
+package androidx.xr.extensions.node {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface InputEvent {
+    method @androidx.xr.extensions.node.InputEvent.Action public int getAction();
+    method public androidx.xr.extensions.node.Vec3 getDirection();
+    method @androidx.xr.extensions.node.InputEvent.DispatchFlag public int getDispatchFlags();
+    method public androidx.xr.extensions.node.InputEvent.HitInfo? getHitInfo();
+    method public androidx.xr.extensions.node.Vec3 getOrigin();
+    method @androidx.xr.extensions.node.InputEvent.PointerType public int getPointerType();
+    method public androidx.xr.extensions.node.InputEvent.HitInfo? getSecondaryHitInfo();
+    method @androidx.xr.extensions.node.InputEvent.Source public int getSource();
+    method public long getTimestamp();
+    field public static final int ACTION_CANCEL = 3; // 0x3
+    field public static final int ACTION_DOWN = 0; // 0x0
+    field public static final int ACTION_HOVER_ENTER = 5; // 0x5
+    field public static final int ACTION_HOVER_EXIT = 6; // 0x6
+    field public static final int ACTION_HOVER_MOVE = 4; // 0x4
+    field public static final int ACTION_MOVE = 2; // 0x2
+    field public static final int ACTION_UP = 1; // 0x1
+    field public static final int DISPATCH_FLAG_2D = 2; // 0x2
+    field public static final int DISPATCH_FLAG_CAPTURED_POINTER = 1; // 0x1
+    field public static final int DISPATCH_FLAG_NONE = 0; // 0x0
+    field public static final int POINTER_TYPE_DEFAULT = 0; // 0x0
+    field public static final int POINTER_TYPE_LEFT = 1; // 0x1
+    field public static final int POINTER_TYPE_RIGHT = 2; // 0x2
+    field public static final int SOURCE_CONTROLLER = 2; // 0x2
+    field public static final int SOURCE_GAZE_AND_GESTURE = 5; // 0x5
+    field public static final int SOURCE_HANDS = 3; // 0x3
+    field public static final int SOURCE_HEAD = 1; // 0x1
+    field public static final int SOURCE_MOUSE = 4; // 0x4
+    field public static final int SOURCE_UNKNOWN = 0; // 0x0
+  }
+
+  @IntDef({androidx.xr.extensions.node.InputEvent.ACTION_DOWN, androidx.xr.extensions.node.InputEvent.ACTION_UP, androidx.xr.extensions.node.InputEvent.ACTION_MOVE, androidx.xr.extensions.node.InputEvent.ACTION_CANCEL, androidx.xr.extensions.node.InputEvent.ACTION_HOVER_MOVE, androidx.xr.extensions.node.InputEvent.ACTION_HOVER_ENTER, androidx.xr.extensions.node.InputEvent.ACTION_HOVER_EXIT}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface InputEvent.Action {
+  }
+
+  @IntDef({androidx.xr.extensions.node.InputEvent.DISPATCH_FLAG_NONE, androidx.xr.extensions.node.InputEvent.DISPATCH_FLAG_CAPTURED_POINTER, androidx.xr.extensions.node.InputEvent.DISPATCH_FLAG_2D}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface InputEvent.DispatchFlag {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static interface InputEvent.HitInfo {
+    method public androidx.xr.extensions.node.Vec3? getHitPosition();
+    method public androidx.xr.extensions.node.Node getInputNode();
+    method public int getSubspaceImpressNodeId();
+    method public androidx.xr.extensions.node.Mat4f getTransform();
+  }
+
+  @IntDef({androidx.xr.extensions.node.InputEvent.POINTER_TYPE_DEFAULT, androidx.xr.extensions.node.InputEvent.POINTER_TYPE_LEFT, androidx.xr.extensions.node.InputEvent.POINTER_TYPE_RIGHT}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface InputEvent.PointerType {
+  }
+
+  @IntDef({androidx.xr.extensions.node.InputEvent.SOURCE_UNKNOWN, androidx.xr.extensions.node.InputEvent.SOURCE_HEAD, androidx.xr.extensions.node.InputEvent.SOURCE_CONTROLLER, androidx.xr.extensions.node.InputEvent.SOURCE_HANDS, androidx.xr.extensions.node.InputEvent.SOURCE_MOUSE, androidx.xr.extensions.node.InputEvent.SOURCE_GAZE_AND_GESTURE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface InputEvent.Source {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Mat4f {
+    ctor public Mat4f(float[]);
+    field public float[]![] m;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Node extends android.os.Parcelable {
+    method public void listenForInput(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.InputEvent!>, java.util.concurrent.Executor);
+    method public void requestPointerCapture(androidx.xr.extensions.Consumer<java.lang.Integer!>, java.util.concurrent.Executor);
+    method public void setNonPointerFocusTarget(android.view.AttachedSurfaceControl);
+    method public void stopListeningForInput();
+    method public void stopPointerCapture();
+    method public java.io.Closeable subscribeToTransform(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.NodeTransform!>, java.util.concurrent.Executor);
+    field public static final int POINTER_CAPTURE_STATE_ACTIVE = 1; // 0x1
+    field public static final int POINTER_CAPTURE_STATE_PAUSED = 0; // 0x0
+    field public static final int POINTER_CAPTURE_STATE_STOPPED = 2; // 0x2
+  }
+
+  @IntDef({androidx.xr.extensions.node.Node.POINTER_CAPTURE_STATE_PAUSED, androidx.xr.extensions.node.Node.POINTER_CAPTURE_STATE_ACTIVE, androidx.xr.extensions.node.Node.POINTER_CAPTURE_STATE_STOPPED}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface Node.PointerCaptureState {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface NodeTransaction extends java.io.Closeable {
+    method public default void apply();
+    method public default void close();
+    method public default androidx.xr.extensions.node.NodeTransaction disableReform(androidx.xr.extensions.node.Node);
+    method public default androidx.xr.extensions.node.NodeTransaction enableReform(androidx.xr.extensions.node.Node, androidx.xr.extensions.node.ReformOptions);
+    method public default androidx.xr.extensions.node.NodeTransaction merge(androidx.xr.extensions.node.NodeTransaction);
+    method public default androidx.xr.extensions.node.NodeTransaction removeCornerRadius(androidx.xr.extensions.node.Node);
+    method public default androidx.xr.extensions.node.NodeTransaction setAlpha(androidx.xr.extensions.node.Node, float);
+    method public default androidx.xr.extensions.node.NodeTransaction setAnchorId(androidx.xr.extensions.node.Node, android.os.IBinder?);
+    method public default androidx.xr.extensions.node.NodeTransaction setCornerRadius(androidx.xr.extensions.node.Node, float);
+    method @Deprecated public default androidx.xr.extensions.node.NodeTransaction setCurvature(androidx.xr.extensions.node.Node, float);
+    method @Deprecated public default androidx.xr.extensions.node.NodeTransaction setEnvironment(androidx.xr.extensions.node.Node, androidx.xr.extensions.asset.EnvironmentToken);
+    method @Deprecated public default androidx.xr.extensions.node.NodeTransaction setGltfAnimation(androidx.xr.extensions.node.Node, String, androidx.xr.extensions.asset.GltfAnimation.State);
+    method @Deprecated public default androidx.xr.extensions.node.NodeTransaction setGltfModel(androidx.xr.extensions.node.Node, androidx.xr.extensions.asset.GltfModelToken);
+    method @Deprecated public default androidx.xr.extensions.node.NodeTransaction setImpressScene(androidx.xr.extensions.node.Node, androidx.xr.extensions.asset.SceneToken);
+    method public default androidx.xr.extensions.node.NodeTransaction setName(androidx.xr.extensions.node.Node, String);
+    method public default androidx.xr.extensions.node.NodeTransaction setOrientation(androidx.xr.extensions.node.Node, float, float, float, float);
+    method public default androidx.xr.extensions.node.NodeTransaction setParent(androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node?);
+    method public default androidx.xr.extensions.node.NodeTransaction setPassthroughState(androidx.xr.extensions.node.Node, float, @androidx.xr.extensions.passthrough.PassthroughState.Mode int);
+    method public default androidx.xr.extensions.node.NodeTransaction setPixelPositioning(androidx.xr.extensions.node.Node, @androidx.xr.extensions.node.NodeTransaction.PixelPositionFlags int);
+    method public default androidx.xr.extensions.node.NodeTransaction setPixelResolution(androidx.xr.extensions.node.Node, float);
+    method public default androidx.xr.extensions.node.NodeTransaction setPosition(androidx.xr.extensions.node.Node, float, float, float);
+    method public default androidx.xr.extensions.node.NodeTransaction setReformSize(androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Vec3);
+    method public default androidx.xr.extensions.node.NodeTransaction setScale(androidx.xr.extensions.node.Node, float, float, float);
+    method public default androidx.xr.extensions.node.NodeTransaction setSubspace(androidx.xr.extensions.node.Node, androidx.xr.extensions.subspace.Subspace);
+    method public default androidx.xr.extensions.node.NodeTransaction setSurfaceControl(androidx.xr.extensions.node.Node?, android.view.SurfaceControl);
+    method public default androidx.xr.extensions.node.NodeTransaction setSurfacePackage(androidx.xr.extensions.node.Node?, android.view.SurfaceControlViewHost.SurfacePackage);
+    method public default androidx.xr.extensions.node.NodeTransaction setVisibility(androidx.xr.extensions.node.Node, boolean);
+    method public default androidx.xr.extensions.node.NodeTransaction setWindowBounds(android.view.SurfaceControl, int, int);
+    method public default androidx.xr.extensions.node.NodeTransaction setWindowBounds(android.view.SurfaceControlViewHost.SurfacePackage, int, int);
+    field public static final int POSITION_FROM_PARENT_TOP_LEFT = 64; // 0x40
+    field public static final int X_POSITION_IN_PIXELS = 1; // 0x1
+    field public static final int Y_POSITION_IN_PIXELS = 2; // 0x2
+    field public static final int Z_POSITION_IN_PIXELS = 4; // 0x4
+  }
+
+  @IntDef(flag=true, value={androidx.xr.extensions.node.NodeTransaction.X_POSITION_IN_PIXELS, androidx.xr.extensions.node.NodeTransaction.Y_POSITION_IN_PIXELS, androidx.xr.extensions.node.NodeTransaction.Z_POSITION_IN_PIXELS, androidx.xr.extensions.node.NodeTransaction.POSITION_FROM_PARENT_TOP_LEFT}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface NodeTransaction.PixelPositionFlags {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface NodeTransform {
+    method public long getTimestamp();
+    method public androidx.xr.extensions.node.Mat4f getTransform();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Quatf {
+    ctor public Quatf(float, float, float, float);
+    field public float w;
+    field public float x;
+    field public float y;
+    field public float z;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ReformEvent {
+    method public androidx.xr.extensions.node.Vec3 getCurrentRayDirection();
+    method public androidx.xr.extensions.node.Vec3 getCurrentRayOrigin();
+    method public int getId();
+    method public androidx.xr.extensions.node.Vec3 getInitialRayDirection();
+    method public androidx.xr.extensions.node.Vec3 getInitialRayOrigin();
+    method public androidx.xr.extensions.node.Quatf getProposedOrientation();
+    method public androidx.xr.extensions.node.Vec3 getProposedPosition();
+    method public androidx.xr.extensions.node.Vec3 getProposedScale();
+    method public androidx.xr.extensions.node.Vec3 getProposedSize();
+    method @androidx.xr.extensions.node.ReformEvent.ReformState public int getState();
+    method @androidx.xr.extensions.node.ReformEvent.ReformType public int getType();
+    field public static final int REFORM_STATE_END = 3; // 0x3
+    field public static final int REFORM_STATE_ONGOING = 2; // 0x2
+    field public static final int REFORM_STATE_START = 1; // 0x1
+    field public static final int REFORM_STATE_UNKNOWN = 0; // 0x0
+    field public static final int REFORM_TYPE_MOVE = 1; // 0x1
+    field public static final int REFORM_TYPE_RESIZE = 2; // 0x2
+    field public static final int REFORM_TYPE_UNKNOWN = 0; // 0x0
+  }
+
+  @IntDef({androidx.xr.extensions.node.ReformEvent.REFORM_STATE_UNKNOWN, androidx.xr.extensions.node.ReformEvent.REFORM_STATE_START, androidx.xr.extensions.node.ReformEvent.REFORM_STATE_ONGOING, androidx.xr.extensions.node.ReformEvent.REFORM_STATE_END}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface ReformEvent.ReformState {
+  }
+
+  @IntDef({androidx.xr.extensions.node.ReformEvent.REFORM_TYPE_UNKNOWN, androidx.xr.extensions.node.ReformEvent.REFORM_TYPE_MOVE, androidx.xr.extensions.node.ReformEvent.REFORM_TYPE_RESIZE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface ReformEvent.ReformType {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ReformOptions {
+    method public androidx.xr.extensions.node.Vec3 getCurrentSize();
+    method @androidx.xr.extensions.node.ReformOptions.AllowedReformTypes public int getEnabledReform();
+    method public androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!> getEventCallback();
+    method public java.util.concurrent.Executor getEventExecutor();
+    method public float getFixedAspectRatio();
+    method @androidx.xr.extensions.node.ReformOptions.ReformFlags public int getFlags();
+    method public default boolean getForceShowResizeOverlay();
+    method public androidx.xr.extensions.node.Vec3 getMaximumSize();
+    method public androidx.xr.extensions.node.Vec3 getMinimumSize();
+    method @androidx.xr.extensions.node.ReformOptions.ScaleWithDistanceMode public default int getScaleWithDistanceMode();
+    method public androidx.xr.extensions.node.ReformOptions setCurrentSize(androidx.xr.extensions.node.Vec3);
+    method public androidx.xr.extensions.node.ReformOptions setEnabledReform(@androidx.xr.extensions.node.ReformOptions.AllowedReformTypes int);
+    method public androidx.xr.extensions.node.ReformOptions setEventCallback(androidx.xr.extensions.Consumer<androidx.xr.extensions.node.ReformEvent!>);
+    method public androidx.xr.extensions.node.ReformOptions setEventExecutor(java.util.concurrent.Executor);
+    method public androidx.xr.extensions.node.ReformOptions setFixedAspectRatio(float);
+    method public androidx.xr.extensions.node.ReformOptions setFlags(@androidx.xr.extensions.node.ReformOptions.ReformFlags int);
+    method public default androidx.xr.extensions.node.ReformOptions setForceShowResizeOverlay(boolean);
+    method public androidx.xr.extensions.node.ReformOptions setMaximumSize(androidx.xr.extensions.node.Vec3);
+    method public androidx.xr.extensions.node.ReformOptions setMinimumSize(androidx.xr.extensions.node.Vec3);
+    method public default androidx.xr.extensions.node.ReformOptions setScaleWithDistanceMode(@androidx.xr.extensions.node.ReformOptions.ScaleWithDistanceMode int);
+    field public static final int ALLOW_MOVE = 1; // 0x1
+    field public static final int ALLOW_RESIZE = 2; // 0x2
+    field public static final int FLAG_ALLOW_SYSTEM_MOVEMENT = 2; // 0x2
+    field public static final int FLAG_POSE_RELATIVE_TO_PARENT = 4; // 0x4
+    field public static final int FLAG_SCALE_WITH_DISTANCE = 1; // 0x1
+    field public static final int SCALE_WITH_DISTANCE_MODE_DEFAULT = 3; // 0x3
+    field public static final int SCALE_WITH_DISTANCE_MODE_DMM = 2; // 0x2
+  }
+
+  @IntDef(flag=true, value={androidx.xr.extensions.node.ReformOptions.ALLOW_MOVE, androidx.xr.extensions.node.ReformOptions.ALLOW_RESIZE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface ReformOptions.AllowedReformTypes {
+  }
+
+  @IntDef(flag=true, value={androidx.xr.extensions.node.ReformOptions.FLAG_SCALE_WITH_DISTANCE, androidx.xr.extensions.node.ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT, androidx.xr.extensions.node.ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface ReformOptions.ReformFlags {
+  }
+
+  @IntDef({androidx.xr.extensions.node.ReformOptions.SCALE_WITH_DISTANCE_MODE_DEFAULT, androidx.xr.extensions.node.ReformOptions.SCALE_WITH_DISTANCE_MODE_DMM}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface ReformOptions.ScaleWithDistanceMode {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Vec3 {
+    ctor public Vec3(float, float, float);
+    field public float x;
+    field public float y;
+    field public float z;
+  }
+
+}
+
+package androidx.xr.extensions.passthrough {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PassthroughState {
+    ctor public PassthroughState();
+    field public static final int PASSTHROUGH_MODE_MAX = 1; // 0x1
+    field public static final int PASSTHROUGH_MODE_MIN = 2; // 0x2
+    field public static final int PASSTHROUGH_MODE_OFF = 0; // 0x0
+  }
+
+  @IntDef({androidx.xr.extensions.passthrough.PassthroughState.PASSTHROUGH_MODE_OFF, androidx.xr.extensions.passthrough.PassthroughState.PASSTHROUGH_MODE_MAX, androidx.xr.extensions.passthrough.PassthroughState.PASSTHROUGH_MODE_MIN}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface PassthroughState.Mode {
+  }
+
+}
+
+package androidx.xr.extensions.space {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ActivityPanel {
+    method public void delete();
+    method public androidx.xr.extensions.node.Node getNode();
+    method public void launchActivity(android.content.Intent, android.os.Bundle?);
+    method public void moveActivity(android.app.Activity);
+    method public void setWindowBounds(android.graphics.Rect);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ActivityPanelLaunchParameters {
+    ctor public ActivityPanelLaunchParameters(android.graphics.Rect);
+    method public android.graphics.Rect getWindowBounds();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Bounds {
+    ctor public Bounds(float, float, float);
+    field public final float depth;
+    field public final float height;
+    field public final float width;
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class BoundsChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public BoundsChangeEvent(androidx.xr.extensions.space.Bounds!);
+    field @Deprecated public androidx.xr.extensions.space.Bounds! bounds;
+    field @Deprecated public float depth;
+    field @Deprecated public float height;
+    field @Deprecated public float width;
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EnvironmentControlChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public EnvironmentControlChangeEvent(boolean);
+    field @Deprecated public boolean environmentControlAllowed;
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EnvironmentVisibilityChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public EnvironmentVisibilityChangeEvent(@androidx.xr.extensions.environment.EnvironmentVisibilityState.State int);
+    field @Deprecated @androidx.xr.extensions.environment.EnvironmentVisibilityState.State public int environmentState;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class HitTestResult {
+    ctor public HitTestResult();
+    field public static final int SURFACE_3D_OBJECT = 2; // 0x2
+    field public static final int SURFACE_PANEL = 1; // 0x1
+    field public static final int SURFACE_UNKNOWN = 0; // 0x0
+    field public float distance;
+    field public androidx.xr.extensions.node.Vec3 hitPosition;
+    field public androidx.xr.extensions.node.Vec3? surfaceNormal;
+    field @androidx.xr.extensions.space.HitTestResult.SurfaceType public int surfaceType;
+    field public boolean virtualEnvironmentIsVisible;
+  }
+
+  @IntDef({androidx.xr.extensions.space.HitTestResult.SURFACE_UNKNOWN, androidx.xr.extensions.space.HitTestResult.SURFACE_PANEL, androidx.xr.extensions.space.HitTestResult.SURFACE_3D_OBJECT}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface HitTestResult.SurfaceType {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PassthroughVisibilityChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public PassthroughVisibilityChangeEvent(@androidx.xr.extensions.environment.PassthroughVisibilityState.State int);
+    field @Deprecated @androidx.xr.extensions.environment.PassthroughVisibilityState.State public int passthroughState;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialCapabilities {
+    method public default boolean get(@androidx.xr.extensions.space.SpatialCapabilities.CapabilityType int);
+    field public static final int APP_ENVIRONMENTS_CAPABLE = 3; // 0x3
+    field public static final int PASSTHROUGH_CONTROL_CAPABLE = 2; // 0x2
+    field public static final int SPATIAL_3D_CONTENTS_CAPABLE = 1; // 0x1
+    field public static final int SPATIAL_ACTIVITY_EMBEDDING_CAPABLE = 5; // 0x5
+    field public static final int SPATIAL_AUDIO_CAPABLE = 4; // 0x4
+    field public static final int SPATIAL_UI_CAPABLE = 0; // 0x0
+  }
+
+  @IntDef({androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_UI_CAPABLE, androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_3D_CONTENTS_CAPABLE, androidx.xr.extensions.space.SpatialCapabilities.PASSTHROUGH_CONTROL_CAPABLE, androidx.xr.extensions.space.SpatialCapabilities.APP_ENVIRONMENTS_CAPABLE, androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_AUDIO_CAPABLE, androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_ACTIVITY_EMBEDDING_CAPABLE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface SpatialCapabilities.CapabilityType {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialCapabilityChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public SpatialCapabilityChangeEvent(androidx.xr.extensions.space.SpatialCapabilities!);
+    field @Deprecated public androidx.xr.extensions.space.SpatialCapabilities! currentCapabilities;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialState {
+    method public default androidx.xr.extensions.space.Bounds getBounds();
+    method public default androidx.xr.extensions.environment.EnvironmentVisibilityState getEnvironmentVisibility();
+    method public default android.util.Size getMainWindowSize();
+    method public default androidx.xr.extensions.environment.PassthroughVisibilityState getPassthroughVisibility();
+    method public default float getPreferredAspectRatio();
+    method public default androidx.xr.extensions.space.SpatialCapabilities getSpatialCapabilities();
+    method public default boolean isActiveEnvironmentNode(androidx.xr.extensions.node.Node?);
+    method public default boolean isActiveSceneNode(androidx.xr.extensions.node.Node?);
+    method public default boolean isActiveWindowLeashNode(androidx.xr.extensions.node.Node?);
+    method public default boolean isEnvironmentInherited();
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class SpatialStateEvent {
+    ctor @Deprecated public SpatialStateEvent();
+  }
+
+  @Deprecated @SuppressCompatibility @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.xr.extensions.ExperimentalExtensionApi public final class VisibilityChangeEvent extends androidx.xr.extensions.space.SpatialStateEvent {
+    ctor @Deprecated public VisibilityChangeEvent(@androidx.xr.extensions.space.VisibilityChangeEvent.SpatialVisibility int);
+    field @Deprecated public static final int HIDDEN = 1; // 0x1
+    field @Deprecated public static final int PARTIALLY_VISIBLE = 2; // 0x2
+    field @Deprecated public static final int UNKNOWN = 0; // 0x0
+    field @Deprecated public static final int VISIBLE = 3; // 0x3
+    field @Deprecated @androidx.xr.extensions.space.VisibilityChangeEvent.SpatialVisibility public int visibility;
+  }
+
+  @Deprecated @IntDef({androidx.xr.extensions.space.VisibilityChangeEvent.UNKNOWN, androidx.xr.extensions.space.VisibilityChangeEvent.HIDDEN, androidx.xr.extensions.space.VisibilityChangeEvent.PARTIALLY_VISIBLE, androidx.xr.extensions.space.VisibilityChangeEvent.VISIBLE}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface VisibilityChangeEvent.SpatialVisibility {
+  }
+
+}
+
+package androidx.xr.extensions.splitengine {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SplitEngineBridge {
+  }
+
+}
+
+package androidx.xr.extensions.subspace {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Subspace {
+  }
+
+}
+
+package androidx.xr.scenecore {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ActivityPanelEntity extends androidx.xr.scenecore.PanelEntity {
+    method public void launchActivity(android.content.Intent intent);
+    method public void launchActivity(android.content.Intent intent, optional android.os.Bundle? bundle);
+    method public void moveActivity(android.app.Activity activity);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ActivityPose {
+    method public androidx.xr.runtime.math.Pose getActivitySpacePose();
+    method public androidx.xr.runtime.math.Pose transformPoseTo(androidx.xr.runtime.math.Pose pose, androidx.xr.scenecore.ActivityPose destination);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ActivitySpace extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace> {
+    method public void addBoundsChangedListener(java.util.concurrent.Executor callbackExecutor, java.util.function.Consumer<androidx.xr.scenecore.Dimensions> listener);
+    method public void addBoundsChangedListener(java.util.function.Consumer<androidx.xr.scenecore.Dimensions> listener);
+    method public androidx.xr.scenecore.Dimensions getBounds();
+    method @Deprecated public void registerOnBoundsChangedListener(androidx.xr.scenecore.OnBoundsChangeListener listener);
+    method public void removeBoundsChangedListener(java.util.function.Consumer<androidx.xr.scenecore.Dimensions> listener);
+    method public void setOnSpaceUpdatedListener(androidx.xr.scenecore.OnSpaceUpdatedListener? listener);
+    method public void setOnSpaceUpdatedListener(androidx.xr.scenecore.OnSpaceUpdatedListener? listener, optional java.util.concurrent.Executor? executor);
+    method @Deprecated public void unregisterOnBoundsChangedListener();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class AnchorEntity extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity> {
+    method public androidx.xr.arcore.Anchor getAnchor(androidx.xr.runtime.Session session);
+    method public androidx.xr.scenecore.AnchorEntity.PersistState getPersistState();
+    method public int getState();
+    method public java.util.UUID? persist();
+    method public void setOnSpaceUpdatedListener(androidx.xr.scenecore.OnSpaceUpdatedListener? listener);
+    method public void setOnSpaceUpdatedListener(androidx.xr.scenecore.OnSpaceUpdatedListener? listener, optional java.util.concurrent.Executor? executor);
+    method public void setOnStateChangedListener(androidx.xr.scenecore.OnStateChangedListener? onStateChangedListener);
+  }
+
+  public enum AnchorEntity.PersistState {
+    enum_constant public static final androidx.xr.scenecore.AnchorEntity.PersistState PERSISTED;
+    enum_constant public static final androidx.xr.scenecore.AnchorEntity.PersistState PERSIST_NOT_REQUESTED;
+    enum_constant public static final androidx.xr.scenecore.AnchorEntity.PersistState PERSIST_PENDING;
+  }
+
+  public static final class AnchorEntity.State {
+    property public static final int ANCHORED;
+    property public static final int ERROR;
+    property public static final int TIMEDOUT;
+    property public static final int UNANCHORED;
+    field public static final int ANCHORED = 0; // 0x0
+    field public static final int ERROR = 3; // 0x3
+    field public static final androidx.xr.scenecore.AnchorEntity.State INSTANCE;
+    field public static final int TIMEDOUT = 2; // 0x2
+    field public static final int UNANCHORED = 1; // 0x1
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class AnchorPlacement {
+    ctor public AnchorPlacement();
+    method public static androidx.xr.scenecore.AnchorPlacement createForPlanes();
+    method public static androidx.xr.scenecore.AnchorPlacement createForPlanes(optional java.util.Set<java.lang.Integer> planeTypeFilter);
+    method public static androidx.xr.scenecore.AnchorPlacement createForPlanes(optional java.util.Set<java.lang.Integer> planeTypeFilter, optional java.util.Set<java.lang.Integer> planeSemanticFilter);
+    field public static final androidx.xr.scenecore.AnchorPlacement.Companion Companion;
+  }
+
+  public static final class AnchorPlacement.Companion {
+    method public androidx.xr.scenecore.AnchorPlacement createForPlanes();
+    method public androidx.xr.scenecore.AnchorPlacement createForPlanes(optional java.util.Set<java.lang.Integer> planeTypeFilter);
+    method public androidx.xr.scenecore.AnchorPlacement createForPlanes(optional java.util.Set<java.lang.Integer> planeTypeFilter, optional java.util.Set<java.lang.Integer> planeSemanticFilter);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class BaseActivityPose<RtActivityPoseType extends androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose> implements androidx.xr.scenecore.ActivityPose {
+    method public androidx.xr.runtime.math.Pose getActivitySpacePose();
+    method public androidx.xr.runtime.math.Pose transformPoseTo(androidx.xr.runtime.math.Pose pose, androidx.xr.scenecore.ActivityPose destination);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class BaseEntity<RtEntityType extends androidx.xr.scenecore.JxrPlatformAdapter.Entity> extends androidx.xr.scenecore.BaseActivityPose<androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose> implements androidx.xr.scenecore.Entity {
+    method public void addChild(androidx.xr.scenecore.Entity child);
+    method public boolean addComponent(androidx.xr.scenecore.Component component);
+    method public void dispose();
+    method public float getActivitySpaceAlpha();
+    method public float getAlpha();
+    method public java.util.List<androidx.xr.scenecore.Component> getComponents();
+    method public <T extends androidx.xr.scenecore.Component> java.util.List<T> getComponentsOfType(Class<? extends T> type);
+    method public androidx.xr.scenecore.Entity? getParent();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public float getScale();
+    method public androidx.xr.scenecore.Dimensions getSize();
+    method public float getWorldSpaceScale();
+    method public boolean isHidden(boolean includeParents);
+    method public void removeAllComponents();
+    method public void removeComponent(androidx.xr.scenecore.Component component);
+    method public void setAlpha(float alpha);
+    method public void setContentDescription(String text);
+    method public void setHidden(boolean hidden);
+    method public void setParent(androidx.xr.scenecore.Entity? parent);
+    method public void setPose(androidx.xr.runtime.math.Pose pose);
+    method public void setScale(float scale);
+    method public void setSize(androidx.xr.scenecore.Dimensions dimensions);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract sealed class BasePanelEntity<RtPanelEntityType extends androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity> extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity> {
+    method public final float getCornerRadius();
+    method public final androidx.xr.runtime.math.Vector3 getPixelDensity();
+    method public final androidx.xr.scenecore.PixelDimensions getPixelDimensions();
+    method public final void setCornerRadius(float radius);
+    method public final void setPixelDimensions(androidx.xr.scenecore.PixelDimensions pxDimensions);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class CameraView extends androidx.xr.scenecore.BaseActivityPose<androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose> {
+    method public androidx.xr.scenecore.CameraView.CameraType getCameraType();
+    method public androidx.xr.scenecore.Fov getFov();
+    property public final androidx.xr.scenecore.CameraView.CameraType cameraType;
+    property public final androidx.xr.scenecore.Fov fov;
+  }
+
+  public enum CameraView.CameraType {
+    enum_constant public static final androidx.xr.scenecore.CameraView.CameraType LEFT_EYE;
+    enum_constant public static final androidx.xr.scenecore.CameraView.CameraType RIGHT_EYE;
+    enum_constant public static final androidx.xr.scenecore.CameraView.CameraType UNKNOWN;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Component {
+    method public boolean onAttach(androidx.xr.scenecore.Entity entity);
+    method public void onDetach(androidx.xr.scenecore.Entity entity);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ContentlessEntity extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.Entity> {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Dimensions {
+    ctor public Dimensions();
+    ctor public Dimensions(optional float width, optional float height, optional float depth);
+    method public float component1();
+    method public float component2();
+    method public float component3();
+    method public androidx.xr.scenecore.Dimensions copy(float width, float height, float depth);
+    method public float getDepth();
+    method public float getHeight();
+    method public float getWidth();
+    property public final float depth;
+    property public final float height;
+    property public final float width;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Entity extends androidx.xr.scenecore.ActivityPose {
+    method public void addChild(androidx.xr.scenecore.Entity child);
+    method public boolean addComponent(androidx.xr.scenecore.Component component);
+    method public void dispose();
+    method public float getActivitySpaceAlpha();
+    method public float getAlpha();
+    method public java.util.List<androidx.xr.scenecore.Component> getComponents();
+    method public <T extends androidx.xr.scenecore.Component> java.util.List<T> getComponentsOfType(Class<? extends T> type);
+    method public androidx.xr.scenecore.Entity? getParent();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public float getScale();
+    method public androidx.xr.scenecore.Dimensions getSize();
+    method public float getWorldSpaceScale();
+    method public boolean isHidden(optional boolean includeParents);
+    method public void removeAllComponents();
+    method public void removeComponent(androidx.xr.scenecore.Component component);
+    method public void setAlpha(float alpha);
+    method public void setContentDescription(String text);
+    method public void setHidden(boolean hidden);
+    method public void setParent(androidx.xr.scenecore.Entity? parent);
+    method public void setPose(androidx.xr.runtime.math.Pose pose);
+    method public void setScale(float scale);
+    method public void setSize(androidx.xr.scenecore.Dimensions dimensions);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ExrImage implements androidx.xr.scenecore.Image {
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource getImage();
+    property public final androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource image;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Fov {
+    ctor public Fov(float angleLeft, float angleRight, float angleUp, float angleDown);
+    method public float component1();
+    method public float component2();
+    method public float component3();
+    method public float component4();
+    method public androidx.xr.scenecore.Fov copy(float angleLeft, float angleRight, float angleUp, float angleDown);
+    method public float getAngleDown();
+    method public float getAngleLeft();
+    method public float getAngleRight();
+    method public float getAngleUp();
+    property public final float angleDown;
+    property public final float angleLeft;
+    property public final float angleRight;
+    property public final float angleUp;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class GltfModel implements androidx.xr.scenecore.Model {
+    method public androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource getModel();
+    property public final androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource model;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class GltfModelEntity extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity> {
+    method public int getAnimationState();
+    method @MainThread public void startAnimation(boolean loop);
+    method @MainThread public void startAnimation(boolean loop, optional String? animationName);
+    method @MainThread public void stopAnimation();
+  }
+
+  public static final class GltfModelEntity.AnimationState {
+    property public static final int PLAYING;
+    property public static final int STOPPED;
+    field public static final androidx.xr.scenecore.GltfModelEntity.AnimationState INSTANCE;
+    field public static final int PLAYING = 0; // 0x0
+    field public static final int STOPPED = 1; // 0x1
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Head extends androidx.xr.scenecore.BaseActivityPose<androidx.xr.scenecore.JxrPlatformAdapter.HeadActivityPose> {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Image {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class InputEvent {
+    ctor public InputEvent(int source, int pointerType, long timestamp, androidx.xr.runtime.math.Vector3 origin, androidx.xr.runtime.math.Vector3 direction, int action, optional androidx.xr.scenecore.InputEvent.HitInfo? hitInfo, optional androidx.xr.scenecore.InputEvent.HitInfo? secondaryHitInfo);
+    method public int getAction();
+    method public androidx.xr.runtime.math.Vector3 getDirection();
+    method public androidx.xr.scenecore.InputEvent.HitInfo? getHitInfo();
+    method public androidx.xr.runtime.math.Vector3 getOrigin();
+    method public int getPointerType();
+    method public androidx.xr.scenecore.InputEvent.HitInfo? getSecondaryHitInfo();
+    method public int getSource();
+    method public long getTimestamp();
+    property public final int action;
+    property public final androidx.xr.runtime.math.Vector3 direction;
+    property public final androidx.xr.scenecore.InputEvent.HitInfo? hitInfo;
+    property public final androidx.xr.runtime.math.Vector3 origin;
+    property public final int pointerType;
+    property public final androidx.xr.scenecore.InputEvent.HitInfo? secondaryHitInfo;
+    property public final int source;
+    property public final long timestamp;
+    field public static final int ACTION_CANCEL = 3; // 0x3
+    field public static final int ACTION_DOWN = 0; // 0x0
+    field public static final int ACTION_HOVER_ENTER = 5; // 0x5
+    field public static final int ACTION_HOVER_EXIT = 6; // 0x6
+    field public static final int ACTION_HOVER_MOVE = 4; // 0x4
+    field public static final int ACTION_MOVE = 2; // 0x2
+    field public static final int ACTION_UP = 1; // 0x1
+    field public static final androidx.xr.scenecore.InputEvent.Companion Companion;
+    field public static final int POINTER_TYPE_DEFAULT = 0; // 0x0
+    field public static final int POINTER_TYPE_LEFT = 1; // 0x1
+    field public static final int POINTER_TYPE_RIGHT = 2; // 0x2
+    field public static final int SOURCE_CONTROLLER = 2; // 0x2
+    field public static final int SOURCE_GAZE_AND_GESTURE = 5; // 0x5
+    field public static final int SOURCE_HANDS = 3; // 0x3
+    field public static final int SOURCE_HEAD = 1; // 0x1
+    field public static final int SOURCE_MOUSE = 4; // 0x4
+    field public static final int SOURCE_UNKNOWN = 0; // 0x0
+  }
+
+  public static final class InputEvent.Companion {
+    property public static final int ACTION_CANCEL;
+    property public static final int ACTION_DOWN;
+    property public static final int ACTION_HOVER_ENTER;
+    property public static final int ACTION_HOVER_EXIT;
+    property public static final int ACTION_HOVER_MOVE;
+    property public static final int ACTION_MOVE;
+    property public static final int ACTION_UP;
+    property public static final int POINTER_TYPE_DEFAULT;
+    property public static final int POINTER_TYPE_LEFT;
+    property public static final int POINTER_TYPE_RIGHT;
+    property public static final int SOURCE_CONTROLLER;
+    property public static final int SOURCE_GAZE_AND_GESTURE;
+    property public static final int SOURCE_HANDS;
+    property public static final int SOURCE_HEAD;
+    property public static final int SOURCE_MOUSE;
+    property public static final int SOURCE_UNKNOWN;
+  }
+
+  public static final class InputEvent.HitInfo {
+    ctor public InputEvent.HitInfo(androidx.xr.scenecore.Entity inputEntity, androidx.xr.runtime.math.Vector3? hitPosition, androidx.xr.runtime.math.Matrix4 transform);
+    method public androidx.xr.runtime.math.Vector3? getHitPosition();
+    method public androidx.xr.scenecore.Entity getInputEntity();
+    method public androidx.xr.runtime.math.Matrix4 getTransform();
+    property public final androidx.xr.runtime.math.Vector3? hitPosition;
+    property public final androidx.xr.scenecore.Entity inputEntity;
+    property public final androidx.xr.runtime.math.Matrix4 transform;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface InputEventListener {
+    method public void onInputEvent(androidx.xr.scenecore.InputEvent inputEvent);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class InteractableComponent implements androidx.xr.scenecore.Component {
+    method public boolean onAttach(androidx.xr.scenecore.Entity entity);
+    method public void onDetach(androidx.xr.scenecore.Entity entity);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface JxrPlatformAdapter {
+    method public void addSpatialCapabilitiesChangedListener(java.util.concurrent.Executor, java.util.function.Consumer<androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities!>);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ActivityPanelEntity createActivityPanelEntity(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions, String, android.app.Activity, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createAnchorEntity(androidx.xr.arcore.Anchor);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createAnchorEntity(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.scenecore.JxrPlatformAdapter.PlaneType, androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic, java.time.Duration);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement createAnchorPlacementForPlanes(java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.PlaneType!>, java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic!>);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity createEntity(androidx.xr.runtime.math.Pose, String, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity createGltfEntity(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource, androidx.xr.scenecore.JxrPlatformAdapter.Entity?);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.InteractableComponent createInteractableComponent(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.LoggingEntity createLoggingEntity(androidx.xr.runtime.math.Pose);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.MovableComponent createMovableComponent(boolean, boolean, java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement!>, boolean);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity createPanelEntity(androidx.xr.runtime.math.Pose, android.view.View, androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, String, android.content.Context, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createPersistedAnchorEntity(java.util.UUID, java.time.Duration);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent createPointerCaptureComponent(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent.StateListener, androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ResizableComponent createResizableComponent(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity createStereoSurfaceEntity(int, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void dispose();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace getActivitySpace();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity getActivitySpaceRootImpl();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AudioTrackExtensionsWrapper getAudioTrackExtensionsWrapper();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose? getCameraViewActivityPose(int);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.HeadActivityPose? getHeadActivityPose();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity getMainPanelEntity();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.MediaPlayerExtensionsWrapper getMediaPlayerExtensionsWrapper();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PerceptionSpaceActivityPose getPerceptionSpaceActivityPose();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SoundPoolExtensionsWrapper getSoundPoolExtensionsWrapper();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities getSpatialCapabilities();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment getSpatialEnvironment();
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource!>? loadExrImageByAssetName(String);
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource!>? loadGltfByAssetName(String);
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource!>? loadGltfByAssetNameSplitEngine(String);
+    method public void removeSpatialCapabilitiesChangedListener(java.util.function.Consumer<androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities!>);
+    method public void requestFullSpaceMode();
+    method public void requestHomeSpaceMode();
+    method public android.os.Bundle setFullSpaceMode(android.os.Bundle);
+    method public android.os.Bundle setFullSpaceModeWithEnvironmentInherited(android.os.Bundle);
+    method public void setPreferredAspectRatio(android.app.Activity, float);
+    method public void startRenderer();
+    method public void stopRenderer();
+    method public boolean unpersistAnchor(java.util.UUID);
+  }
+
+  public static interface JxrPlatformAdapter.ActivityPanelEntity extends androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity {
+    method public void launchActivity(android.content.Intent, android.os.Bundle?);
+    method public void moveActivity(android.app.Activity);
+  }
+
+  public static interface JxrPlatformAdapter.ActivityPose {
+    method public androidx.xr.runtime.math.Pose getActivitySpacePose();
+    method public androidx.xr.runtime.math.Vector3 getActivitySpaceScale();
+    method public androidx.xr.runtime.math.Vector3 getWorldSpaceScale();
+    method public androidx.xr.runtime.math.Pose transformPoseTo(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose);
+  }
+
+  public static interface JxrPlatformAdapter.ActivitySpace extends androidx.xr.scenecore.JxrPlatformAdapter.SystemSpaceEntity {
+    method public void addOnBoundsChangedListener(androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Dimensions getBounds();
+    method public void removeOnBoundsChangedListener(androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener);
+  }
+
+  public static interface JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener {
+    method public void onBoundsChanged(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+  }
+
+  public static interface JxrPlatformAdapter.AnchorEntity extends androidx.xr.scenecore.JxrPlatformAdapter.SystemSpaceEntity {
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState getPersistState();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State getState();
+    method public long nativePointer();
+    method public java.util.UUID? persist();
+    method public void registerPersistStateChangeListener(androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistStateChangeListener);
+    method public void setOnStateChangedListener(androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.OnStateChangedListener?);
+  }
+
+  public static interface JxrPlatformAdapter.AnchorEntity.OnStateChangedListener {
+    method public void onStateChanged(androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State);
+  }
+
+  public enum JxrPlatformAdapter.AnchorEntity.PersistState {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState PERSISTED;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState PERSIST_NOT_REQUESTED;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState PERSIST_PENDING;
+  }
+
+  public static interface JxrPlatformAdapter.AnchorEntity.PersistStateChangeListener {
+    method public void onPersistStateChanged(androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState);
+  }
+
+  public enum JxrPlatformAdapter.AnchorEntity.State {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State ANCHORED;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State ERROR;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State TIMED_OUT;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State UNANCHORED;
+  }
+
+  public static interface JxrPlatformAdapter.AnchorPlacement {
+  }
+
+  public static interface JxrPlatformAdapter.AudioTrackExtensionsWrapper {
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes? getPointSourceAttributes(android.media.AudioTrack);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes? getSoundFieldAttributes(android.media.AudioTrack);
+    method public int getSpatialSourceType(android.media.AudioTrack);
+    method public android.media.AudioTrack.Builder setPointSourceAttributes(android.media.AudioTrack.Builder, androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes);
+    method public android.media.AudioTrack.Builder setSoundFieldAttributes(android.media.AudioTrack.Builder, androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes);
+  }
+
+  public static interface JxrPlatformAdapter.CameraViewActivityPose extends androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose {
+    method public int getCameraType();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose.Fov getFov();
+    field public static final int CAMERA_TYPE_LEFT_EYE = 1; // 0x1
+    field public static final int CAMERA_TYPE_RIGHT_EYE = 2; // 0x2
+    field public static final int CAMERA_TYPE_UNKNOWN = 0; // 0x0
+  }
+
+  public static class JxrPlatformAdapter.CameraViewActivityPose.Fov {
+    ctor public JxrPlatformAdapter.CameraViewActivityPose.Fov(float, float, float, float);
+    field public final float angleDown;
+    field public final float angleLeft;
+    field public final float angleRight;
+    field public final float angleUp;
+  }
+
+  public static interface JxrPlatformAdapter.Component {
+    method public boolean onAttach(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void onDetach(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+  }
+
+  public static class JxrPlatformAdapter.Dimensions {
+    ctor public JxrPlatformAdapter.Dimensions(float, float, float);
+    field public float depth;
+    field public float height;
+    field public float width;
+  }
+
+  public static interface JxrPlatformAdapter.Entity extends androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose {
+    method public void addChild(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void addChildren(java.util.List<androidx.xr.scenecore.JxrPlatformAdapter.Entity!>);
+    method public boolean addComponent(androidx.xr.scenecore.JxrPlatformAdapter.Component);
+    method public void addInputEventListener(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public void dispose();
+    method public float getActivitySpaceAlpha();
+    method public float getAlpha();
+    method public java.util.List<androidx.xr.scenecore.JxrPlatformAdapter.Entity!> getChildren();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity? getParent();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.math.Vector3 getScale();
+    method public boolean isHidden(boolean);
+    method public void removeAllComponents();
+    method public void removeComponent(androidx.xr.scenecore.JxrPlatformAdapter.Component);
+    method public void removeInputEventListener(androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public void setAlpha(float);
+    method public void setContentDescription(String);
+    method public void setHidden(boolean);
+    method public void setParent(androidx.xr.scenecore.JxrPlatformAdapter.Entity?);
+    method public void setPose(androidx.xr.runtime.math.Pose);
+    method public void setScale(androidx.xr.runtime.math.Vector3);
+    method public void setSize(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+  }
+
+  public static interface JxrPlatformAdapter.ExrImageResource extends androidx.xr.scenecore.JxrPlatformAdapter.Resource {
+  }
+
+  public static interface JxrPlatformAdapter.GltfEntity extends androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+    method public int getAnimationState();
+    method public void startAnimation(boolean, String?);
+    method public void stopAnimation();
+  }
+
+  public static interface JxrPlatformAdapter.GltfModelResource extends androidx.xr.scenecore.JxrPlatformAdapter.Resource {
+  }
+
+  public static interface JxrPlatformAdapter.HeadActivityPose extends androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose {
+  }
+
+  public static class JxrPlatformAdapter.InputEvent {
+    ctor public JxrPlatformAdapter.InputEvent(int, int, long, androidx.xr.runtime.math.Vector3, androidx.xr.runtime.math.Vector3, int, androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo?, androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo?);
+    field public static final int ACTION_CANCEL = 3; // 0x3
+    field public static final int ACTION_DOWN = 0; // 0x0
+    field public static final int ACTION_HOVER_ENTER = 5; // 0x5
+    field public static final int ACTION_HOVER_EXIT = 6; // 0x6
+    field public static final int ACTION_HOVER_MOVE = 4; // 0x4
+    field public static final int ACTION_MOVE = 2; // 0x2
+    field public static final int ACTION_UP = 1; // 0x1
+    field public static final int POINTER_TYPE_DEFAULT = 0; // 0x0
+    field public static final int POINTER_TYPE_LEFT = 1; // 0x1
+    field public static final int POINTER_TYPE_RIGHT = 2; // 0x2
+    field public static final int SOURCE_CONTROLLER = 2; // 0x2
+    field public static final int SOURCE_GAZE_AND_GESTURE = 5; // 0x5
+    field public static final int SOURCE_HANDS = 3; // 0x3
+    field public static final int SOURCE_HEAD = 1; // 0x1
+    field public static final int SOURCE_MOUSE = 4; // 0x4
+    field public static final int SOURCE_UNKNOWN = 0; // 0x0
+    field public int action;
+    field public androidx.xr.runtime.math.Vector3 direction;
+    field public androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo? hitInfo;
+    field public androidx.xr.runtime.math.Vector3 origin;
+    field public int pointerType;
+    field public androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo? secondaryHitInfo;
+    field public int source;
+    field public long timestamp;
+  }
+
+  public static class JxrPlatformAdapter.InputEvent.HitInfo {
+    ctor public JxrPlatformAdapter.InputEvent.HitInfo(androidx.xr.scenecore.JxrPlatformAdapter.Entity?, androidx.xr.runtime.math.Vector3?, androidx.xr.runtime.math.Matrix4);
+    field public final androidx.xr.runtime.math.Vector3? hitPosition;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Entity? inputEntity;
+    field public final androidx.xr.runtime.math.Matrix4 transform;
+  }
+
+  @java.lang.FunctionalInterface public static interface JxrPlatformAdapter.InputEventListener {
+    method public void onInputEvent(androidx.xr.scenecore.JxrPlatformAdapter.InputEvent);
+  }
+
+  public static interface JxrPlatformAdapter.InteractableComponent extends androidx.xr.scenecore.JxrPlatformAdapter.Component {
+  }
+
+  public static interface JxrPlatformAdapter.LoggingEntity extends androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+  }
+
+  public static interface JxrPlatformAdapter.MediaPlayerExtensionsWrapper {
+    method public void setPointSourceAttributes(android.media.MediaPlayer, androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes);
+    method public void setSoundFieldAttributes(android.media.MediaPlayer, androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes);
+  }
+
+  public static interface JxrPlatformAdapter.MovableComponent extends androidx.xr.scenecore.JxrPlatformAdapter.Component {
+    method public void addMoveEventListener(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.MoveEventListener);
+    method public int getScaleWithDistanceMode();
+    method public void removeMoveEventListener(androidx.xr.scenecore.JxrPlatformAdapter.MoveEventListener);
+    method public void setScaleWithDistanceMode(int);
+    method public void setSize(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+  }
+
+  public static class JxrPlatformAdapter.MoveEvent {
+    ctor public JxrPlatformAdapter.MoveEvent(int, androidx.xr.scenecore.JxrPlatformAdapter.Ray, androidx.xr.scenecore.JxrPlatformAdapter.Ray, androidx.xr.runtime.math.Pose, androidx.xr.runtime.math.Pose, androidx.xr.runtime.math.Vector3, androidx.xr.runtime.math.Vector3, androidx.xr.scenecore.JxrPlatformAdapter.Entity, androidx.xr.scenecore.JxrPlatformAdapter.Entity?, androidx.xr.scenecore.JxrPlatformAdapter.Entity?);
+    field public static final int MOVE_STATE_END = 3; // 0x3
+    field public static final int MOVE_STATE_ONGOING = 2; // 0x2
+    field public static final int MOVE_STATE_START = 1; // 0x1
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Ray currentInputRay;
+    field public final androidx.xr.runtime.math.Pose currentPose;
+    field public final androidx.xr.runtime.math.Vector3 currentScale;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Entity? disposedEntity;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Ray initialInputRay;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Entity initialParent;
+    field public final int moveState;
+    field public final androidx.xr.runtime.math.Pose previousPose;
+    field public final androidx.xr.runtime.math.Vector3 previousScale;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Entity? updatedParent;
+  }
+
+  @java.lang.FunctionalInterface public static interface JxrPlatformAdapter.MoveEventListener {
+    method public void onMoveEvent(androidx.xr.scenecore.JxrPlatformAdapter.MoveEvent);
+  }
+
+  public static interface JxrPlatformAdapter.PanelEntity extends androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+    method public float getCornerRadius();
+    method public androidx.xr.runtime.math.Vector3 getPixelDensity();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions getPixelDimensions();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Dimensions getSize();
+    method public void setCornerRadius(float);
+    method public void setPixelDimensions(androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions);
+  }
+
+  public static interface JxrPlatformAdapter.PerceptionSpaceActivityPose extends androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose {
+  }
+
+  public static class JxrPlatformAdapter.PixelDimensions {
+    ctor public JxrPlatformAdapter.PixelDimensions(int, int);
+    field public final int height;
+    field public final int width;
+  }
+
+  public enum JxrPlatformAdapter.PlaneSemantic {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic ANY;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic CEILING;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic FLOOR;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic TABLE;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic WALL;
+  }
+
+  public enum JxrPlatformAdapter.PlaneType {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneType ANY;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneType HORIZONTAL;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.PlaneType VERTICAL;
+  }
+
+  public static class JxrPlatformAdapter.PointSourceAttributes {
+    ctor public JxrPlatformAdapter.PointSourceAttributes(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity getEntity();
+  }
+
+  public static interface JxrPlatformAdapter.PointerCaptureComponent extends androidx.xr.scenecore.JxrPlatformAdapter.Component {
+    field public static final int POINTER_CAPTURE_STATE_ACTIVE = 1; // 0x1
+    field public static final int POINTER_CAPTURE_STATE_PAUSED = 0; // 0x0
+    field public static final int POINTER_CAPTURE_STATE_STOPPED = 2; // 0x2
+  }
+
+  public static interface JxrPlatformAdapter.PointerCaptureComponent.StateListener {
+    method public void onStateChanged(int);
+  }
+
+  public static class JxrPlatformAdapter.Ray {
+    ctor public JxrPlatformAdapter.Ray(androidx.xr.runtime.math.Vector3, androidx.xr.runtime.math.Vector3);
+    field public final androidx.xr.runtime.math.Vector3 direction;
+    field public final androidx.xr.runtime.math.Vector3 origin;
+  }
+
+  public static interface JxrPlatformAdapter.ResizableComponent extends androidx.xr.scenecore.JxrPlatformAdapter.Component {
+    method public void addResizeEventListener(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.ResizeEventListener);
+    method public void removeResizeEventListener(androidx.xr.scenecore.JxrPlatformAdapter.ResizeEventListener);
+    method public void setFixedAspectRatio(float);
+    method public void setMaximumSize(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    method public void setMinimumSize(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    method public void setSize(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+  }
+
+  public static class JxrPlatformAdapter.ResizeEvent {
+    ctor public JxrPlatformAdapter.ResizeEvent(int, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    field public static final int RESIZE_STATE_END = 3; // 0x3
+    field public static final int RESIZE_STATE_ONGOING = 2; // 0x2
+    field public static final int RESIZE_STATE_START = 1; // 0x1
+    field public static final int RESIZE_STATE_UNKNOWN = 0; // 0x0
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.Dimensions newSize;
+    field public final int resizeState;
+  }
+
+  @java.lang.FunctionalInterface public static interface JxrPlatformAdapter.ResizeEventListener {
+    method public void onResizeEvent(androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent);
+  }
+
+  public static interface JxrPlatformAdapter.Resource {
+  }
+
+  public static class JxrPlatformAdapter.SoundFieldAttributes {
+    ctor public JxrPlatformAdapter.SoundFieldAttributes(int);
+    method public int getAmbisonicsOrder();
+  }
+
+  public static interface JxrPlatformAdapter.SoundPoolExtensionsWrapper {
+    method public int getSpatialSourceType(android.media.SoundPool, int);
+    method public int play(android.media.SoundPool, int, androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes, float, int, int, float);
+    method public int play(android.media.SoundPool, int, androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes, float, int, int, float);
+  }
+
+  public static class JxrPlatformAdapter.SpatialCapabilities {
+    ctor public JxrPlatformAdapter.SpatialCapabilities(int);
+    method public boolean hasCapability(int);
+    field public static final int SPATIAL_CAPABILITY_3D_CONTENT = 2; // 0x2
+    field public static final int SPATIAL_CAPABILITY_APP_ENVIRONMENT = 8; // 0x8
+    field public static final int SPATIAL_CAPABILITY_EMBED_ACTIVITY = 32; // 0x20
+    field public static final int SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL = 4; // 0x4
+    field public static final int SPATIAL_CAPABILITY_SPATIAL_AUDIO = 16; // 0x10
+    field public static final int SPATIAL_CAPABILITY_UI = 1; // 0x1
+    field public int capabilities;
+  }
+
+  public static interface JxrPlatformAdapter.SpatialEnvironment {
+    method public void addOnPassthroughOpacityChangedListener(java.util.function.Consumer<java.lang.Float!>);
+    method public void addOnSpatialEnvironmentChangedListener(java.util.function.Consumer<java.lang.Boolean!>);
+    method public float getCurrentPassthroughOpacity();
+    method public Float? getPassthroughOpacityPreference();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference? getSpatialEnvironmentPreference();
+    method public boolean isSpatialEnvironmentPreferenceActive();
+    method public void removeOnPassthroughOpacityChangedListener(java.util.function.Consumer<java.lang.Float!>);
+    method public void removeOnSpatialEnvironmentChangedListener(java.util.function.Consumer<java.lang.Boolean!>);
+    method @com.google.errorprone.annotations.CanIgnoreReturnValue public androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult setPassthroughOpacityPreference(Float?);
+    method @com.google.errorprone.annotations.CanIgnoreReturnValue public androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult setSpatialEnvironmentPreference(androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference?);
+  }
+
+  public enum JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult CHANGE_APPLIED;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult CHANGE_PENDING;
+  }
+
+  public enum JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult {
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult CHANGE_APPLIED;
+    enum_constant public static final androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult CHANGE_PENDING;
+  }
+
+  public static class JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference {
+    ctor public JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference(androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource?, androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource?);
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource? geometry;
+    field public final androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource? skybox;
+  }
+
+  public static final class JxrPlatformAdapter.SpatializerConstants {
+    field public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+    field public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+    field public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+    field public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+    field public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+    field public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+  }
+
+  public static interface JxrPlatformAdapter.StereoSurfaceEntity extends androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Dimensions getDimensions();
+    method public int getStereoMode();
+    method public android.view.Surface getSurface();
+    method public void setDimensions(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    method public void setStereoMode(int);
+  }
+
+  public static interface JxrPlatformAdapter.SystemSpaceEntity extends androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+    method public void setOnSpaceUpdatedListener(androidx.xr.scenecore.JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener?, java.util.concurrent.Executor?);
+  }
+
+  @java.lang.FunctionalInterface public static interface JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener {
+    method public void onSpaceUpdated();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Model {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class MovableComponent implements androidx.xr.scenecore.Component {
+    method public void addMoveListener(java.util.concurrent.Executor executor, androidx.xr.scenecore.MoveListener moveListener);
+    method public androidx.xr.scenecore.Dimensions getSize();
+    method public boolean onAttach(androidx.xr.scenecore.Entity entity);
+    method public void onDetach(androidx.xr.scenecore.Entity entity);
+    method public void removeMoveListener(androidx.xr.scenecore.MoveListener moveListener);
+    method public void setSize(androidx.xr.scenecore.Dimensions);
+    property public final androidx.xr.scenecore.Dimensions size;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface MoveListener {
+    method public default void onMoveEnd(androidx.xr.scenecore.Entity entity, androidx.xr.runtime.math.Ray finalInputRay, androidx.xr.runtime.math.Pose finalPose, float finalScale, androidx.xr.scenecore.Entity? updatedParent);
+    method public default void onMoveStart(androidx.xr.scenecore.Entity entity, androidx.xr.runtime.math.Ray initialInputRay, androidx.xr.runtime.math.Pose initialPose, float initialScale, androidx.xr.scenecore.Entity initialParent);
+    method public default void onMoveUpdate(androidx.xr.scenecore.Entity entity, androidx.xr.runtime.math.Ray currentInputRay, androidx.xr.runtime.math.Pose currentPose, float currentScale);
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface OnBoundsChangeListener {
+    method @Deprecated public void onBoundsChanged(androidx.xr.scenecore.Dimensions bounds);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface OnSpaceUpdatedListener {
+    method public void onSpaceUpdated();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public fun interface OnStateChangedListener {
+    method public void onStateChanged(int newState);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PanelEntity extends androidx.xr.scenecore.BasePanelEntity<androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity> {
+    method public final boolean isMainPanelEntity();
+    property public final boolean isMainPanelEntity;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PerceptionSpace extends androidx.xr.scenecore.BaseActivityPose<androidx.xr.scenecore.JxrPlatformAdapter.PerceptionSpaceActivityPose> {
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PermissionHelper {
+    method public boolean hasPermission(android.app.Activity activity, String permission);
+    method public void launchPermissionSettings(android.app.Activity activity);
+    method public void requestPermission(android.app.Activity activity, String permission, int permissionCode);
+    method public boolean shouldShowRequestPermissionRationale(android.app.Activity activity, String permission);
+    property public static final String SCENE_UNDERSTANDING_PERMISSION;
+    property public static final int SCENE_UNDERSTANDING_PERMISSION_CODE;
+    field public static final androidx.xr.scenecore.PermissionHelper INSTANCE;
+    field public static final String SCENE_UNDERSTANDING_PERMISSION = "android.permission.SCENE_UNDERSTANDING";
+    field public static final int SCENE_UNDERSTANDING_PERMISSION_CODE = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PixelDimensions {
+    ctor public PixelDimensions();
+    ctor public PixelDimensions(optional int width, optional int height);
+    method public int component1();
+    method public int component2();
+    method public androidx.xr.scenecore.PixelDimensions copy(int width, int height);
+    method public int getHeight();
+    method public int getWidth();
+    property public final int height;
+    property public final int width;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PlaneSemantic {
+    property public static final int ANY;
+    property public static final int CEILING;
+    property public static final int FLOOR;
+    property public static final int TABLE;
+    property public static final int WALL;
+    field public static final int ANY = 4; // 0x4
+    field public static final int CEILING = 2; // 0x2
+    field public static final int FLOOR = 1; // 0x1
+    field public static final androidx.xr.scenecore.PlaneSemantic INSTANCE;
+    field public static final int TABLE = 3; // 0x3
+    field public static final int WALL = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PlaneType {
+    property public static final int ANY;
+    property public static final int HORIZONTAL;
+    property public static final int VERTICAL;
+    field public static final int ANY = 2; // 0x2
+    field public static final int HORIZONTAL = 0; // 0x0
+    field public static final androidx.xr.scenecore.PlaneType INSTANCE;
+    field public static final int VERTICAL = 1; // 0x1
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PointSourceAttributes {
+    ctor public PointSourceAttributes(androidx.xr.scenecore.Entity entity);
+    method public androidx.xr.scenecore.Entity getEntity();
+    property public final androidx.xr.scenecore.Entity entity;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PointerCaptureComponent implements androidx.xr.scenecore.Component {
+    method public static androidx.xr.scenecore.PointerCaptureComponent create(androidx.xr.scenecore.Session session, java.util.concurrent.Executor executor, androidx.xr.scenecore.PointerCaptureComponent.StateListener stateListener, androidx.xr.scenecore.InputEventListener inputListener);
+    method public boolean onAttach(androidx.xr.scenecore.Entity entity);
+    method public void onDetach(androidx.xr.scenecore.Entity entity);
+    field public static final androidx.xr.scenecore.PointerCaptureComponent.Companion Companion;
+    field public static final int POINTER_CAPTURE_STATE_ACTIVE = 1; // 0x1
+    field public static final int POINTER_CAPTURE_STATE_PAUSED = 0; // 0x0
+    field public static final int POINTER_CAPTURE_STATE_STOPPED = 2; // 0x2
+  }
+
+  public static final class PointerCaptureComponent.Companion {
+    method public androidx.xr.scenecore.PointerCaptureComponent create(androidx.xr.scenecore.Session session, java.util.concurrent.Executor executor, androidx.xr.scenecore.PointerCaptureComponent.StateListener stateListener, androidx.xr.scenecore.InputEventListener inputListener);
+    property public static final int POINTER_CAPTURE_STATE_ACTIVE;
+    property public static final int POINTER_CAPTURE_STATE_PAUSED;
+    property public static final int POINTER_CAPTURE_STATE_STOPPED;
+  }
+
+  public static interface PointerCaptureComponent.StateListener {
+    method public void onStateChanged(int newState);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ResizableComponent implements androidx.xr.scenecore.Component {
+    method public void addResizeListener(java.util.concurrent.Executor executor, androidx.xr.scenecore.ResizeListener resizeListener);
+    method public float getFixedAspectRatio();
+    method public androidx.xr.scenecore.Dimensions getMaximumSize();
+    method public androidx.xr.scenecore.Dimensions getMinimumSize();
+    method public androidx.xr.scenecore.Dimensions getSize();
+    method public boolean onAttach(androidx.xr.scenecore.Entity entity);
+    method public void onDetach(androidx.xr.scenecore.Entity entity);
+    method public void removeResizeListener(androidx.xr.scenecore.ResizeListener resizeListener);
+    method public void setFixedAspectRatio(float);
+    method public void setMaximumSize(androidx.xr.scenecore.Dimensions);
+    method public void setMinimumSize(androidx.xr.scenecore.Dimensions);
+    method public void setSize(androidx.xr.scenecore.Dimensions);
+    property public final float fixedAspectRatio;
+    property public final androidx.xr.scenecore.Dimensions maximumSize;
+    property public final androidx.xr.scenecore.Dimensions minimumSize;
+    property public final androidx.xr.scenecore.Dimensions size;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface ResizeListener {
+    method public default void onResizeEnd(androidx.xr.scenecore.Entity entity, androidx.xr.scenecore.Dimensions finalSize);
+    method public default void onResizeStart(androidx.xr.scenecore.Entity entity, androidx.xr.scenecore.Dimensions originalSize);
+    method public default void onResizeUpdate(androidx.xr.scenecore.Entity entity, androidx.xr.scenecore.Dimensions newSize);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Session {
+    ctor public Session(android.app.Activity activity, androidx.xr.scenecore.JxrPlatformAdapter runtime, androidx.xr.scenecore.SpatialEnvironment spatialEnvironment);
+    method public void addSpatialCapabilitiesChangedListener(java.util.concurrent.Executor callbackExecutor, java.util.function.Consumer<androidx.xr.scenecore.SpatialCapabilities> listener);
+    method public void addSpatialCapabilitiesChangedListener(java.util.function.Consumer<androidx.xr.scenecore.SpatialCapabilities> listener);
+    method @Deprecated public boolean canEmbedActivityPanel(android.app.Activity activity);
+    method public static androidx.xr.scenecore.Session create(android.app.Activity activity);
+    method public static androidx.xr.scenecore.Session create(android.app.Activity activity, optional androidx.xr.scenecore.JxrPlatformAdapter? runtime);
+    method public androidx.xr.scenecore.ActivityPanelEntity createActivityPanelEntity(android.graphics.Rect windowBoundsPx, String name);
+    method public androidx.xr.scenecore.ActivityPanelEntity createActivityPanelEntity(android.graphics.Rect windowBoundsPx, String name, optional androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.scenecore.AnchorEntity createAnchorEntity(androidx.xr.arcore.Anchor anchor);
+    method public androidx.xr.scenecore.AnchorEntity createAnchorEntity(androidx.xr.scenecore.Dimensions bounds, int planeType, int planeSemantic);
+    method public androidx.xr.scenecore.AnchorEntity createAnchorEntity(androidx.xr.scenecore.Dimensions bounds, int planeType, int planeSemantic, optional java.time.Duration timeout);
+    method public androidx.xr.scenecore.Entity createEntity(String name);
+    method public androidx.xr.scenecore.Entity createEntity(String name, optional androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.scenecore.ExrImage createExrImageResource(String name);
+    method @MainThread public androidx.xr.scenecore.GltfModelEntity createGltfEntity(androidx.xr.scenecore.GltfModel model);
+    method @MainThread public androidx.xr.scenecore.GltfModelEntity createGltfEntity(androidx.xr.scenecore.GltfModel model, optional androidx.xr.runtime.math.Pose pose);
+    method @MainThread public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.GltfModel> createGltfResourceAsync(String name);
+    method public androidx.xr.scenecore.InteractableComponent createInteractableComponent(java.util.concurrent.Executor executor, androidx.xr.scenecore.InputEventListener inputEventListener);
+    method public androidx.xr.scenecore.MovableComponent createMovableComponent();
+    method public androidx.xr.scenecore.MovableComponent createMovableComponent(optional boolean systemMovable);
+    method public androidx.xr.scenecore.MovableComponent createMovableComponent(optional boolean systemMovable, optional boolean scaleInZ);
+    method public androidx.xr.scenecore.MovableComponent createMovableComponent(optional boolean systemMovable, optional boolean scaleInZ, optional java.util.Set<androidx.xr.scenecore.AnchorPlacement> anchorPlacement);
+    method public androidx.xr.scenecore.MovableComponent createMovableComponent(optional boolean systemMovable, optional boolean scaleInZ, optional java.util.Set<androidx.xr.scenecore.AnchorPlacement> anchorPlacement, optional boolean shouldDisposeParentAnchor);
+    method public androidx.xr.scenecore.PanelEntity createPanelEntity(android.view.View view, androidx.xr.scenecore.Dimensions surfaceDimensionsPx, androidx.xr.scenecore.Dimensions dimensions, String name);
+    method public androidx.xr.scenecore.PanelEntity createPanelEntity(android.view.View view, androidx.xr.scenecore.Dimensions surfaceDimensionsPx, androidx.xr.scenecore.Dimensions dimensions, String name, optional androidx.xr.runtime.math.Pose pose);
+    method public androidx.xr.scenecore.AnchorEntity createPersistedAnchorEntity(java.util.UUID uuid);
+    method public androidx.xr.scenecore.ResizableComponent createResizableComponent();
+    method public androidx.xr.scenecore.ResizableComponent createResizableComponent(optional androidx.xr.scenecore.Dimensions minimumSize);
+    method public androidx.xr.scenecore.ResizableComponent createResizableComponent(optional androidx.xr.scenecore.Dimensions minimumSize, optional androidx.xr.scenecore.Dimensions maximumSize);
+    method @MainThread public androidx.xr.scenecore.StereoSurfaceEntity createStereoSurfaceEntity();
+    method @MainThread public androidx.xr.scenecore.StereoSurfaceEntity createStereoSurfaceEntity(optional int stereoMode);
+    method @MainThread public androidx.xr.scenecore.StereoSurfaceEntity createStereoSurfaceEntity(optional int stereoMode, optional androidx.xr.scenecore.Dimensions dimensions);
+    method @MainThread public androidx.xr.scenecore.StereoSurfaceEntity createStereoSurfaceEntity(optional int stereoMode, optional androidx.xr.scenecore.Dimensions dimensions, optional androidx.xr.runtime.math.Pose pose);
+    method public android.app.Activity getActivity();
+    method public androidx.xr.scenecore.ActivitySpace getActivitySpace();
+    method public androidx.xr.scenecore.Entity getActivitySpaceRoot();
+    method public <T extends androidx.xr.scenecore.Entity> java.util.List<T> getEntitiesOfType(Class<? extends T> type);
+    method public androidx.xr.scenecore.PanelEntity getMainPanelEntity();
+    method public androidx.xr.scenecore.PerceptionSpace getPerceptionSpace();
+    method public androidx.xr.scenecore.JxrPlatformAdapter getRuntime();
+    method public androidx.xr.scenecore.SpatialCapabilities getSpatialCapabilities();
+    method public androidx.xr.scenecore.SpatialEnvironment getSpatialEnvironment();
+    method public androidx.xr.scenecore.SpatialUser getSpatialUser();
+    method @Deprecated public boolean hasSpatialCapability(int capability);
+    method public void removeSpatialCapabilitiesChangedListener(java.util.function.Consumer<androidx.xr.scenecore.SpatialCapabilities> listener);
+    method public void requestFullSpaceMode();
+    method public void requestHomeSpaceMode();
+    method public android.os.Bundle setFullSpaceMode(android.os.Bundle bundle);
+    method public android.os.Bundle setFullSpaceModeWithEnvironmentInherited(android.os.Bundle bundle);
+    method public void setPreferredAspectRatio(android.app.Activity activity, float preferredRatio);
+    method public boolean unpersistAnchor(java.util.UUID uuid);
+    property public final android.app.Activity activity;
+    property public final androidx.xr.scenecore.ActivitySpace activitySpace;
+    property public final androidx.xr.scenecore.Entity activitySpaceRoot;
+    property public final androidx.xr.scenecore.PanelEntity mainPanelEntity;
+    property public final androidx.xr.scenecore.PerceptionSpace perceptionSpace;
+    property public final androidx.xr.scenecore.JxrPlatformAdapter runtime;
+    property public final androidx.xr.scenecore.SpatialEnvironment spatialEnvironment;
+    property public final androidx.xr.scenecore.SpatialUser spatialUser;
+    field public static final androidx.xr.scenecore.Session.Companion Companion;
+  }
+
+  public static final class Session.Companion {
+    method public androidx.xr.scenecore.Session create(android.app.Activity activity);
+    method public androidx.xr.scenecore.Session create(android.app.Activity activity, optional androidx.xr.scenecore.JxrPlatformAdapter? runtime);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SoundFieldAttributes {
+    ctor public SoundFieldAttributes(int order);
+    method public int getOrder();
+    property public final int order;
+    field public static final String TAG = "SoundFieldAttributes";
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialAudioTrack {
+    ctor public SpatialAudioTrack();
+    method public static androidx.xr.scenecore.PointSourceAttributes? getPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+    method public static androidx.xr.scenecore.SoundFieldAttributes? getSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+    method public static int getSpatialSourceType(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+    field public static final androidx.xr.scenecore.SpatialAudioTrack.Companion Companion;
+  }
+
+  public static final class SpatialAudioTrack.Companion {
+    method public androidx.xr.scenecore.PointSourceAttributes? getPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+    method public androidx.xr.scenecore.SoundFieldAttributes? getSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+    method public int getSpatialSourceType(androidx.xr.scenecore.Session session, android.media.AudioTrack track);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialAudioTrackBuilder {
+    method public static android.media.AudioTrack.Builder setPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack.Builder builder, androidx.xr.scenecore.PointSourceAttributes attributes);
+    method public static android.media.AudioTrack.Builder setSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack.Builder builder, androidx.xr.scenecore.SoundFieldAttributes attributes);
+    field public static final androidx.xr.scenecore.SpatialAudioTrackBuilder.Companion Companion;
+  }
+
+  public static final class SpatialAudioTrackBuilder.Companion {
+    method public android.media.AudioTrack.Builder setPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack.Builder builder, androidx.xr.scenecore.PointSourceAttributes attributes);
+    method public android.media.AudioTrack.Builder setSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.AudioTrack.Builder builder, androidx.xr.scenecore.SoundFieldAttributes attributes);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialCapabilities {
+    ctor public SpatialCapabilities(int capabilities);
+    method public boolean hasCapability(int capability);
+    field public static final androidx.xr.scenecore.SpatialCapabilities.Companion Companion;
+    field public static final int SPATIAL_CAPABILITY_3D_CONTENT = 2; // 0x2
+    field public static final int SPATIAL_CAPABILITY_APP_ENVIRONMENT = 8; // 0x8
+    field public static final int SPATIAL_CAPABILITY_EMBED_ACTIVITY = 32; // 0x20
+    field public static final int SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL = 4; // 0x4
+    field public static final int SPATIAL_CAPABILITY_SPATIAL_AUDIO = 16; // 0x10
+    field public static final int SPATIAL_CAPABILITY_UI = 1; // 0x1
+  }
+
+  public static final class SpatialCapabilities.Companion {
+    property public static final int SPATIAL_CAPABILITY_3D_CONTENT;
+    property public static final int SPATIAL_CAPABILITY_APP_ENVIRONMENT;
+    property public static final int SPATIAL_CAPABILITY_EMBED_ACTIVITY;
+    property public static final int SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL;
+    property public static final int SPATIAL_CAPABILITY_SPATIAL_AUDIO;
+    property public static final int SPATIAL_CAPABILITY_UI;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialEnvironment {
+    ctor public SpatialEnvironment(androidx.xr.scenecore.JxrPlatformAdapter runtime);
+    method public void addOnPassthroughOpacityChangedListener(java.util.function.Consumer<java.lang.Float> listener);
+    method public void addOnSpatialEnvironmentChangedListener(java.util.function.Consumer<java.lang.Boolean> listener);
+    method public float getCurrentPassthroughOpacity();
+    method @Deprecated public androidx.xr.scenecore.SpatialEnvironment.PassthroughMode getPassthroughMode();
+    method @Deprecated public float getPassthroughOpacity();
+    method public Float? getPassthroughOpacityPreference();
+    method public androidx.xr.scenecore.SpatialEnvironment.SpatialEnvironmentPreference? getSpatialEnvironmentPreference();
+    method public boolean isSpatialEnvironmentPreferenceActive();
+    method public void removeOnPassthroughOpacityChangedListener(java.util.function.Consumer<java.lang.Float> listener);
+    method public void removeOnSpatialEnvironmentChangedListener(java.util.function.Consumer<java.lang.Boolean> listener);
+    method @Deprecated public void setGeometry(androidx.xr.scenecore.GltfModel? gltfModel);
+    method @Deprecated public void setPassthrough(androidx.xr.scenecore.SpatialEnvironment.PassthroughMode passthroughMode);
+    method @Deprecated public void setPassthroughOpacity(float passthroughOpacity);
+    method @com.google.errorprone.annotations.CanIgnoreReturnValue public androidx.xr.scenecore.SpatialEnvironment.SetPassthroughOpacityPreferenceResult setPassthroughOpacityPreference(Float? passthroughOpacityPreference);
+    method @Deprecated public void setSkybox(androidx.xr.scenecore.ExrImage? exrImage);
+    method @com.google.errorprone.annotations.CanIgnoreReturnValue public androidx.xr.scenecore.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult setSpatialEnvironmentPreference(androidx.xr.scenecore.SpatialEnvironment.SpatialEnvironmentPreference? environmentPreference);
+  }
+
+  @Deprecated public static final class SpatialEnvironment.PassthroughMode {
+    method @Deprecated public int getValue();
+    property @Deprecated public final int value;
+    field @Deprecated public static final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode.Companion Companion;
+    field @Deprecated public static final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Disabled;
+    field @Deprecated public static final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Enabled;
+    field @Deprecated public static final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Uninitialized;
+  }
+
+  @Deprecated public static final class SpatialEnvironment.PassthroughMode.Companion {
+    property @Deprecated public final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Disabled;
+    property @Deprecated public final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Enabled;
+    property @Deprecated public final androidx.xr.scenecore.SpatialEnvironment.PassthroughMode Uninitialized;
+  }
+
+  public static final class SpatialEnvironment.SetPassthroughOpacityPreferenceChangeApplied extends androidx.xr.scenecore.SpatialEnvironment.SetPassthroughOpacityPreferenceResult {
+    ctor public SpatialEnvironment.SetPassthroughOpacityPreferenceChangeApplied();
+  }
+
+  public static final class SpatialEnvironment.SetPassthroughOpacityPreferenceChangePending extends androidx.xr.scenecore.SpatialEnvironment.SetPassthroughOpacityPreferenceResult {
+    ctor public SpatialEnvironment.SetPassthroughOpacityPreferenceChangePending();
+  }
+
+  public abstract static sealed class SpatialEnvironment.SetPassthroughOpacityPreferenceResult {
+  }
+
+  public static final class SpatialEnvironment.SetSpatialEnvironmentPreferenceChangeApplied extends androidx.xr.scenecore.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult {
+    ctor public SpatialEnvironment.SetSpatialEnvironmentPreferenceChangeApplied();
+  }
+
+  public static final class SpatialEnvironment.SetSpatialEnvironmentPreferenceChangePending extends androidx.xr.scenecore.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult {
+    ctor public SpatialEnvironment.SetSpatialEnvironmentPreferenceChangePending();
+  }
+
+  public abstract static sealed class SpatialEnvironment.SetSpatialEnvironmentPreferenceResult {
+  }
+
+  public static final class SpatialEnvironment.SpatialEnvironmentPreference {
+    ctor public SpatialEnvironment.SpatialEnvironmentPreference(androidx.xr.scenecore.ExrImage? skybox, androidx.xr.scenecore.GltfModel? geometry);
+    method public androidx.xr.scenecore.GltfModel? getGeometry();
+    method public androidx.xr.scenecore.ExrImage? getSkybox();
+    property public final androidx.xr.scenecore.GltfModel? geometry;
+    property public final androidx.xr.scenecore.ExrImage? skybox;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialMediaPlayer {
+    ctor public SpatialMediaPlayer();
+    method public static void setPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.MediaPlayer mediaPlayer, androidx.xr.scenecore.PointSourceAttributes attributes);
+    method public static void setSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.MediaPlayer mediaPlayer, androidx.xr.scenecore.SoundFieldAttributes attributes);
+    field public static final androidx.xr.scenecore.SpatialMediaPlayer.Companion Companion;
+  }
+
+  public static final class SpatialMediaPlayer.Companion {
+    method public void setPointSourceAttributes(androidx.xr.scenecore.Session session, android.media.MediaPlayer mediaPlayer, androidx.xr.scenecore.PointSourceAttributes attributes);
+    method public void setSoundFieldAttributes(androidx.xr.scenecore.Session session, android.media.MediaPlayer mediaPlayer, androidx.xr.scenecore.SoundFieldAttributes attributes);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialSoundPool {
+    method public static int getSpatialSourceType(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int streamId);
+    method public static int play(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int soundID, androidx.xr.scenecore.PointSourceAttributes attributes, float volume, int priority, int loop, float rate);
+    method public static int play(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int soundID, androidx.xr.scenecore.SoundFieldAttributes attributes, float volume, int priority, int loop, float rate);
+    field public static final androidx.xr.scenecore.SpatialSoundPool.Companion Companion;
+  }
+
+  public static final class SpatialSoundPool.Companion {
+    method public int getSpatialSourceType(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int streamId);
+    method public int play(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int soundID, androidx.xr.scenecore.PointSourceAttributes attributes, float volume, int priority, int loop, float rate);
+    method public int play(androidx.xr.scenecore.Session session, android.media.SoundPool soundPool, int soundID, androidx.xr.scenecore.SoundFieldAttributes attributes, float volume, int priority, int loop, float rate);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialUser {
+    ctor public SpatialUser(androidx.xr.scenecore.JxrPlatformAdapter runtime);
+    method public androidx.xr.scenecore.CameraView? getCameraView(androidx.xr.scenecore.CameraView.CameraType cameraType);
+    method public java.util.List<androidx.xr.scenecore.CameraView> getCameraViews();
+    method public androidx.xr.scenecore.Head? getHead();
+    method public void setHead(androidx.xr.scenecore.Head?);
+    property public final androidx.xr.scenecore.Head? head;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatializerConstants {
+    field public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+    field public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+    field public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+    field public static final androidx.xr.scenecore.SpatializerConstants.Companion Companion;
+    field public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+    field public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+    field public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+  }
+
+  public static final class SpatializerConstants.Companion {
+    property public static final int AMBISONICS_ORDER_FIRST_ORDER;
+    property public static final int AMBISONICS_ORDER_SECOND_ORDER;
+    property public static final int AMBISONICS_ORDER_THIRD_ORDER;
+    property public static final int SOURCE_TYPE_BYPASS;
+    property public static final int SOURCE_TYPE_POINT_SOURCE;
+    property public static final int SOURCE_TYPE_SOUND_FIELD;
+    field public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+    field public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+    field public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+    field public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+    field public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+    field public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class StereoSurfaceEntity extends androidx.xr.scenecore.BaseEntity<androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity> {
+    method public androidx.xr.scenecore.Dimensions getDimensions();
+    method @MainThread public android.view.Surface getSurface();
+    method public void setDimensions(androidx.xr.scenecore.Dimensions);
+    property public final androidx.xr.scenecore.Dimensions dimensions;
+  }
+
+  public static final class StereoSurfaceEntity.StereoMode {
+    property public static final int MONO;
+    property public static final int SIDE_BY_SIDE;
+    property public static final int TOP_BOTTOM;
+    field public static final androidx.xr.scenecore.StereoSurfaceEntity.StereoMode INSTANCE;
+    field public static final int MONO = 0; // 0x0
+    field public static final int SIDE_BY_SIDE = 2; // 0x2
+    field public static final int TOP_BOTTOM = 1; // 0x1
+  }
+
+}
+
+package androidx.xr.scenecore.common {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class BaseActivityPose implements androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose {
+    ctor public BaseActivityPose();
+    method public androidx.xr.runtime.math.Pose getActivitySpacePose();
+    method public androidx.xr.runtime.math.Vector3 getActivitySpaceScale();
+    method public androidx.xr.runtime.math.Pose getPoseInActivitySpace();
+    method public androidx.xr.runtime.math.Vector3 getWorldSpaceScale();
+    method public androidx.xr.runtime.math.Pose transformPoseTo(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class BaseEntity extends androidx.xr.scenecore.common.BaseActivityPose implements androidx.xr.scenecore.JxrPlatformAdapter.Entity {
+    ctor public BaseEntity();
+    method public void addChild(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method protected void addChildInternal(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void addChildren(java.util.List<androidx.xr.scenecore.JxrPlatformAdapter.Entity!>);
+    method public boolean addComponent(androidx.xr.scenecore.JxrPlatformAdapter.Component);
+    method public void dispose();
+    method public float getActivitySpaceAlpha();
+    method public float getAlpha();
+    method public java.util.List<androidx.xr.scenecore.JxrPlatformAdapter.Entity!> getChildren();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity? getParent();
+    method public androidx.xr.runtime.math.Pose getPose();
+    method public androidx.xr.runtime.math.Vector3 getScale();
+    method public boolean isHidden(boolean);
+    method public void removeAllComponents();
+    method protected void removeChildInternal(androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void removeComponent(androidx.xr.scenecore.JxrPlatformAdapter.Component);
+    method public void setAlpha(float);
+    method public void setContentDescription(String);
+    method public void setHidden(boolean);
+    method public void setParent(androidx.xr.scenecore.JxrPlatformAdapter.Entity?);
+    method public void setPose(androidx.xr.runtime.math.Pose);
+    method public void setScale(androidx.xr.runtime.math.Vector3);
+    method protected final void setScaleInternal(androidx.xr.runtime.math.Vector3);
+  }
+
+}
+
+package androidx.xr.scenecore.impl {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class JxrPlatformAdapterAxr implements androidx.xr.scenecore.JxrPlatformAdapter {
+    method public void addSpatialCapabilitiesChangedListener(java.util.concurrent.Executor, java.util.function.Consumer<androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities!>);
+    method public static androidx.xr.scenecore.impl.JxrPlatformAdapterAxr create(android.app.Activity, java.util.concurrent.ScheduledExecutorService);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.scenecore.impl.JxrPlatformAdapterAxr create(android.app.Activity, java.util.concurrent.ScheduledExecutorService, androidx.xr.extensions.node.Node, androidx.xr.extensions.node.Node);
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.scenecore.impl.JxrPlatformAdapterAxr create(android.app.Activity, java.util.concurrent.ScheduledExecutorService, androidx.xr.extensions.XrExtensions, com.google.ar.imp.apibindings.ImpressApi?, androidx.xr.scenecore.impl.perception.PerceptionLibrary, com.google.androidxr.splitengine.SplitEngineSubspaceManager?, com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer?);
+    method public static androidx.xr.scenecore.impl.JxrPlatformAdapterAxr create(android.app.Activity, java.util.concurrent.ScheduledExecutorService, boolean);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ActivityPanelEntity createActivityPanelEntity(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions, String, android.app.Activity, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createAnchorEntity(androidx.xr.arcore.Anchor);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createAnchorEntity(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.scenecore.JxrPlatformAdapter.PlaneType, androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic, java.time.Duration);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement createAnchorPlacementForPlanes(java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.PlaneType!>, java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic!>);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity createEntity(androidx.xr.runtime.math.Pose, String, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity createGltfEntity(androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource, androidx.xr.scenecore.JxrPlatformAdapter.Entity?);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.InteractableComponent createInteractableComponent(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.LoggingEntity createLoggingEntity(androidx.xr.runtime.math.Pose);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.MovableComponent createMovableComponent(boolean, boolean, java.util.Set<androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement!>, boolean);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity createPanelEntity(androidx.xr.runtime.math.Pose, android.view.View, androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, String, android.content.Context, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity createPersistedAnchorEntity(java.util.UUID, java.time.Duration);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent createPointerCaptureComponent(java.util.concurrent.Executor, androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent.StateListener, androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ResizableComponent createResizableComponent(androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity createStereoSurfaceEntity(int, androidx.xr.scenecore.JxrPlatformAdapter.Dimensions, androidx.xr.runtime.math.Pose, androidx.xr.scenecore.JxrPlatformAdapter.Entity);
+    method public void dispose();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace getActivitySpace();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.Entity getActivitySpaceRootImpl();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.AudioTrackExtensionsWrapper getAudioTrackExtensionsWrapper();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose? getCameraViewActivityPose(int);
+    method public androidx.xr.scenecore.JxrPlatformAdapter.HeadActivityPose? getHeadActivityPose();
+    method public androidx.xr.runtime.math.Pose? getHeadPoseInOpenXrUnboundedSpace();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity getMainPanelEntity();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.MediaPlayerExtensionsWrapper getMediaPlayerExtensionsWrapper();
+    method public long getNativeInstance();
+    method public long getNativeSession();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.PerceptionSpaceActivityPose getPerceptionSpaceActivityPose();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SoundPoolExtensionsWrapper getSoundPoolExtensionsWrapper();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities getSpatialCapabilities();
+    method public androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment getSpatialEnvironment();
+    method public androidx.xr.scenecore.impl.perception.ViewProjections? getStereoViewsInOpenXrUnboundedSpace();
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource!>? loadExrImageByAssetName(String);
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource!>? loadGltfByAssetName(String);
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource!>? loadGltfByAssetNameSplitEngine(String);
+    method public void removeSpatialCapabilitiesChangedListener(java.util.function.Consumer<androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities!>);
+    method public void requestFullSpaceMode();
+    method public void requestHomeSpaceMode();
+    method public android.os.Bundle setFullSpaceMode(android.os.Bundle);
+    method public android.os.Bundle setFullSpaceModeWithEnvironmentInherited(android.os.Bundle);
+    method public void setPreferredAspectRatio(android.app.Activity, float);
+    method public void setSplitEngineSubspaceManager(com.google.androidxr.splitengine.SplitEngineSubspaceManager?);
+    method public void startRenderer();
+    method public void stopRenderer();
+    method public boolean unpersistAnchor(java.util.UUID);
+  }
+
+  public final class Matrix4Ext {
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static androidx.xr.runtime.math.Matrix4 getUnscaled(androidx.xr.runtime.math.Matrix4);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class MediaUtils {
+  }
+
+}
+
+package androidx.xr.scenecore.impl.perception {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Anchor {
+    ctor public Anchor(long, android.os.IBinder);
+    method public boolean detach();
+    method public long getAnchorId();
+    method public android.os.IBinder getAnchorToken();
+    method public androidx.xr.scenecore.impl.perception.Anchor.PersistState getPersistState();
+    method public java.util.UUID? persist();
+  }
+
+  public enum Anchor.PersistState {
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Anchor.PersistState NOT_VALID;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Anchor.PersistState PERSISTED;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Anchor.PersistState PERSIST_NOT_REQUESTED;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Anchor.PersistState PERSIST_PENDING;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Fov {
+    ctor public Fov(float, float, float, float);
+    method public float getAngleDown();
+    method public float getAngleLeft();
+    method public float getAngleRight();
+    method public float getAngleUp();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PerceptionLibrary {
+    ctor public PerceptionLibrary();
+    method public androidx.xr.scenecore.impl.perception.Session? getSession();
+    method public com.google.common.util.concurrent.ListenableFuture<androidx.xr.scenecore.impl.perception.Session!>? initSession(android.app.Activity, int, java.util.concurrent.ExecutorService);
+    method protected static void loadLibraryAsync(String);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class PerceptionLibraryConstants {
+    field public static final int OPEN_XR_SPACE_TYPE_LOCAL = 2; // 0x2
+    field public static final int OPEN_XR_SPACE_TYPE_LOCAL_FLOOR = 1000426000; // 0x3ba14a10
+    field public static final int OPEN_XR_SPACE_TYPE_STAGE = 3; // 0x3
+    field public static final int OPEN_XR_SPACE_TYPE_UNBOUNDED = 1000467000; // 0x3ba1ea38
+    field public static final int OPEN_XR_SPACE_TYPE_VIEW = 1; // 0x1
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Plane implements androidx.xr.scenecore.impl.perception.Trackable {
+    ctor public Plane(Long, int);
+    method public androidx.xr.scenecore.impl.perception.Anchor? createAnchor(androidx.xr.scenecore.impl.perception.Pose, Long?);
+    method public java.util.List<androidx.xr.scenecore.impl.perception.Anchor!> getAnchors();
+    method public androidx.xr.scenecore.impl.perception.Plane.PlaneData? getData(Long?);
+  }
+
+  public enum Plane.Label {
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Label CEILING;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Label FLOOR;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Label TABLE;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Label UNKNOWN;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Label WALL;
+    field public final int intValue;
+  }
+
+  public static class Plane.PlaneData {
+    ctor public Plane.PlaneData(androidx.xr.scenecore.impl.perception.Pose, float, float, int, int);
+    field public final androidx.xr.scenecore.impl.perception.Pose centerPose;
+    field public final float extentHeight;
+    field public final float extentWidth;
+    field public final androidx.xr.scenecore.impl.perception.Plane.Label label;
+    field public final androidx.xr.scenecore.impl.perception.Plane.Type type;
+  }
+
+  public enum Plane.Type {
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Type ARBITRARY;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Type HORIZONTAL_DOWNWARD_FACING;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Type HORIZONTAL_UPWARD_FACING;
+    enum_constant public static final androidx.xr.scenecore.impl.perception.Plane.Type VERTICAL;
+    field public final int intValue;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Pose {
+    ctor public Pose(float, float, float, float, float, float, float);
+    method public static androidx.xr.scenecore.impl.perception.Pose identity();
+    method public float qw();
+    method public float qx();
+    method public float qy();
+    method public float qz();
+    method public float tx();
+    method public float ty();
+    method public float tz();
+    method public void updateRotation(float, float, float, float);
+    method public void updateTranslation(float, float, float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Session {
+    method public androidx.xr.scenecore.impl.perception.Anchor? createAnchor(float, float, androidx.xr.scenecore.impl.perception.Plane.Type, androidx.xr.scenecore.impl.perception.Plane.Label);
+    method public androidx.xr.scenecore.impl.perception.Anchor? createAnchorFromUuid(java.util.UUID?);
+    method public java.util.List<androidx.xr.scenecore.impl.perception.Plane!> getAllPlanes();
+    method public androidx.xr.scenecore.impl.perception.Pose? getHeadPose();
+    method public long getNativeInstance();
+    method public long getNativeSession();
+    method public androidx.xr.scenecore.impl.perception.ViewProjections? getStereoViews();
+    method public boolean unpersistAnchor(java.util.UUID?);
+    field public static final long XR_NULL_HANDLE = 0L; // 0x0L
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Trackable {
+    method public androidx.xr.scenecore.impl.perception.Anchor? createAnchor(androidx.xr.scenecore.impl.perception.Pose, Long?);
+    method public java.util.List<androidx.xr.scenecore.impl.perception.Anchor!> getAnchors();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class ViewProjection {
+    ctor public ViewProjection(androidx.xr.scenecore.impl.perception.Pose, androidx.xr.scenecore.impl.perception.Fov);
+    method public androidx.xr.scenecore.impl.perception.Fov getFov();
+    method public androidx.xr.scenecore.impl.perception.Pose getPose();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ViewProjections {
+    ctor public ViewProjections(androidx.xr.scenecore.impl.perception.ViewProjection, androidx.xr.scenecore.impl.perception.ViewProjection);
+    method public androidx.xr.scenecore.impl.perception.ViewProjection getLeftEye();
+    method public androidx.xr.scenecore.impl.perception.ViewProjection getRightEye();
+  }
+
+}
+
+package androidx.xr.scenecore.impl.perception.exceptions {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class FailedToInitializeException extends java.lang.RuntimeException {
+    ctor public FailedToInitializeException(String);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class LibraryLoadingException extends java.lang.RuntimeException {
+    ctor public LibraryLoadingException(String);
+  }
+
+}
+
diff --git a/xr/scenecore/scenecore/build.gradle b/xr/scenecore/scenecore/build.gradle
new file mode 100644
index 0000000..af4f2d3
--- /dev/null
+++ b/xr/scenecore/scenecore/build.gradle
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.KotlinTarget
+
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+    id("com.google.protobuf")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(project(":xr:arcore:arcore"))
+    api(project(":xr:runtime:runtime"))
+
+    implementation("androidx.annotation:annotation:1.8.1")
+    implementation("androidx.savedstate:savedstate:1.2.1")
+    implementation("androidx.concurrent:concurrent-futures:1.0.0")
+    implementation("androidx.activity:activity:1.9.3")
+    implementation("com.google.errorprone:error_prone_annotations:2.30.0")
+    implementation("androidx.lifecycle:lifecycle-common:2.8.7")
+    implementation("androidx.lifecycle:lifecycle-runtime:2.8.7")
+    implementation(project(":xr:runtime:runtime-openxr"))
+    implementation("com.google.ar:impress:0.0.1")
+    implementation(libs.guavaListenableFuture)
+    implementation(libs.guavaAndroid)
+    implementation(libs.protobufLite)
+
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testExtTruth)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+    testImplementation(libs.mockitoCore)
+    testImplementation(libs.mockitoKotlin)
+    testImplementation(project(":xr:scenecore:scenecore-testing"))
+    testImplementation(project(":xr:runtime:runtime-testing"))
+
+    // For androidx.xr.extensions.
+    compileOnly(project(':xr:xr-stubs'))
+    testImplementation(project(':xr:xr-stubs'))
+}
+
+android {
+    defaultConfig {
+        consumerProguardFiles 'proguard-rules.pro'
+        // TODO: This should be lower, possibly 21.
+        //       Address API calls that require higher versions.
+        minSdkVersion 30
+    }
+    sourceSets {
+        test {
+            assets {
+                srcDirs += ["src/test/java/androidx/xr/scenecore/impl/fake_assets"]
+            }
+        }
+    }
+    namespace "androidx.xr.scenecore"
+}
+
+androidx {
+    name = "XR SceneCore"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Scene building libraries for the androidx.xr namespace."
+
+    // TODO: b/379715750 - Remove this flag once the deprecated methods have been removed from the API.
+    failOnDeprecationWarnings = false
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/scenecore/scenecore/proguard-rules.pro b/xr/scenecore/scenecore/proguard-rules.pro
new file mode 100644
index 0000000..4593d71
--- /dev/null
+++ b/xr/scenecore/scenecore/proguard-rules.pro
@@ -0,0 +1,14 @@
+# Keep all classes in the extensions and scenecore packages since they can be used externally
+-keep public class androidx.xr.extensions.** { *; }
+-keep public interface androidx.xr.extensions.** { *; }
+-keep @interface androidx.xr.extensions.**
+
+-keep class androidx.xr.scenecore.** { *; }
+-keep class androidx.xr.scenecore.**$* { *; }
+-keep class androidx.xr.scenecore.impl.** { *; }
+-keep class androidx.xr.scenecore.impl.**$* { *; }
+
+-keep class * extends androidx.xr.scenecore.** { *; }
+-keep class * extends androidx.xr.scenecore.**$* { *; }
+-keep class * extends androidx.xr.scenecore.impl.** { *; }
+-keep class * extends androidx.xr.scenecore.impl.**$* { *; }
diff --git a/xr/scenecore/scenecore/src/main/AndroidManifest.xml b/xr/scenecore/scenecore/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4c0f76a
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<!--
+  Copyright 2024 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application>
+        <uses-library android:name="com.android.extensions.xr" android:required="false" />
+    </application>
+</manifest>
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/AndroidXrExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/AndroidXrExtensions.java
new file mode 100644
index 0000000..9d4141a
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/AndroidXrExtensions.java
@@ -0,0 +1,473 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import static java.util.Objects.requireNonNull;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.asset.EnvironmentToken;
+import androidx.xr.extensions.asset.GltfModelToken;
+import androidx.xr.extensions.asset.SceneToken;
+import androidx.xr.extensions.asset.TokenConverter;
+import androidx.xr.extensions.media.MediaTypeConverter;
+import androidx.xr.extensions.media.XrSpatialAudioExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.node.NodeTypeConverter;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.extensions.space.ActivityPanel;
+import androidx.xr.extensions.space.ActivityPanelLaunchParameters;
+import androidx.xr.extensions.space.Bounds;
+import androidx.xr.extensions.space.HitTestResult;
+import androidx.xr.extensions.space.SpaceTypeConverter;
+import androidx.xr.extensions.space.SpatialCapabilities;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.extensions.space.SpatialStateEvent;
+import androidx.xr.extensions.splitengine.SplitEngineBridge;
+import androidx.xr.extensions.splitengine.SplitEngineTypeConverter;
+import androidx.xr.extensions.subspace.Subspace;
+import androidx.xr.extensions.subspace.SubspaceTypeConverter;
+
+import java.io.InputStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+
+/**
+ * The main extensions class that creates or provides instances of various XR Extensions components.
+ *
+ * <p>This class wraps the com.android.extensions.xr.XrExtensions class.
+ */
+class AndroidXrExtensions implements XrExtensions {
+    @NonNull private final com.android.extensions.xr.XrExtensions mExtensions;
+
+    AndroidXrExtensions(@NonNull com.android.extensions.xr.XrExtensions extensions) {
+        requireNonNull(extensions);
+        mExtensions = extensions;
+    }
+
+    @Override
+    public int getApiVersion() {
+        return mExtensions.getApiVersion();
+    }
+
+    @Override
+    public @NonNull Node createNode() {
+        return NodeTypeConverter.toLibrary(mExtensions.createNode());
+    }
+
+    @Override
+    public @NonNull NodeTransaction createNodeTransaction() {
+        return NodeTypeConverter.toLibrary(mExtensions.createNodeTransaction());
+    }
+
+    @Override
+    public @NonNull Subspace createSubspace(
+            @NonNull SplitEngineBridge splitEngineBridge, int subspaceId) {
+        com.android.extensions.xr.splitengine.SplitEngineBridge bridge =
+                SplitEngineTypeConverter.toFramework(splitEngineBridge);
+
+        return SubspaceTypeConverter.toLibrary(mExtensions.createSubspace(bridge, subspaceId));
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull CompletableFuture</* @Nullable */ GltfModelToken> loadGltfModel(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes, String url) {
+        return mExtensions
+                .loadGltfModel(asset, regionSizeBytes, regionOffsetBytes, url)
+                .thenApply(token -> TokenConverter.toLibrary(token));
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull CompletableFuture</* @Nullable */ SceneViewerResult> displayGltfModel(
+            Activity activity, GltfModelToken gltfModel) {
+        return mExtensions
+                .displayGltfModel(activity, TokenConverter.toFramework(gltfModel))
+                .thenApply(result -> (result == null) ? null : new SceneViewerResult());
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull CompletableFuture</* @Nullable */ EnvironmentToken> loadEnvironment(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes, String url) {
+        // This method has been deprecated on the platform side. Hard  code width and height to 256.
+        return loadEnvironment(
+                asset,
+                regionSizeBytes,
+                regionOffsetBytes,
+                url,
+                /*default texture width*/ 256, /*default texture height*/
+                256);
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull CompletableFuture</* @Nullable */ EnvironmentToken> loadEnvironment(
+            InputStream asset,
+            int regionSizeBytes,
+            int regionOffsetBytes,
+            String url,
+            int textureWidth,
+            int textureHeight) {
+        return mExtensions
+                .loadEnvironment(
+                        asset, regionSizeBytes, regionOffsetBytes, url, textureWidth, textureHeight)
+                .thenApply(token -> TokenConverter.toLibrary(token));
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull CompletableFuture</* @Nullable */ SceneToken> loadImpressScene(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes) {
+        return mExtensions
+                .loadImpressScene(asset, regionSizeBytes, regionOffsetBytes)
+                .thenApply(token -> TokenConverter.toLibrary(token));
+    }
+
+    @Override
+    public @NonNull SplitEngineBridge createSplitEngineBridge() {
+        return SplitEngineTypeConverter.toLibrary(mExtensions.createSplitEngineBridge());
+    }
+
+    @Override
+    public @NonNull XrSpatialAudioExtensions getXrSpatialAudioExtensions() {
+        return MediaTypeConverter.toLibrary(mExtensions.getXrSpatialAudioExtensions());
+    }
+
+    @Override
+    @Deprecated
+    public void attachSpatialScene(
+            @NonNull Activity activity, @NonNull Node sceneNode, @NonNull Node windowNode) {
+        mExtensions.attachSpatialScene(
+                activity,
+                NodeTypeConverter.toFramework(sceneNode),
+                NodeTypeConverter.toFramework(windowNode));
+    }
+
+    @Override
+    public void attachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Node sceneNode,
+            @NonNull Node windowNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.attachSpatialScene(
+                activity,
+                NodeTypeConverter.toFramework(sceneNode),
+                NodeTypeConverter.toFramework(windowNode),
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void detachSpatialScene(@NonNull Activity activity) {
+        mExtensions.detachSpatialScene(activity);
+    }
+
+    @Override
+    public void detachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.detachSpatialScene(
+                activity,
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void setMainWindowSize(@NonNull Activity activity, int width, int height) {
+        mExtensions.setMainWindowSize(activity, width, height);
+    }
+
+    @Override
+    public void setMainWindowSize(
+            @NonNull Activity activity,
+            int width,
+            int height,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.setMainWindowSize(
+                activity,
+                width,
+                height,
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void setMainWindowCurvatureRadius(@NonNull Activity activity, float curvatureRadius) {
+        mExtensions.setMainWindowCurvatureRadius(activity, curvatureRadius);
+    }
+
+    @Override
+    @Deprecated
+    public void attachSpatialEnvironment(
+            @NonNull Activity activity, @NonNull Node environmentNode) {
+        mExtensions.attachSpatialEnvironment(
+                activity, NodeTypeConverter.toFramework(environmentNode));
+    }
+
+    @Override
+    public void attachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Node environmentNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.attachSpatialEnvironment(
+                activity,
+                NodeTypeConverter.toFramework(environmentNode),
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void detachSpatialEnvironment(@NonNull Activity activity) {
+        mExtensions.detachSpatialEnvironment(activity);
+    }
+
+    @Override
+    public void detachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.detachSpatialEnvironment(
+                activity,
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void setSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialStateEvent> callback,
+            @NonNull Executor executor) {
+        mExtensions.setSpatialStateCallbackDeprecated(
+                activity,
+                /* callback= */ (event) -> {
+                    callback.accept(SpaceTypeConverter.toLibrary(event));
+                },
+                executor);
+    }
+
+    @Override
+    public void registerSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialState> callback,
+            @NonNull Executor executor) {
+        mExtensions.setSpatialStateCallback(
+                activity,
+                /* callback= */ (state) -> {
+                    callback.accept(SpaceTypeConverter.toLibrary(state));
+                },
+                executor);
+    }
+
+    @Override
+    public void clearSpatialStateCallback(@NonNull Activity activity) {
+        mExtensions.clearSpatialStateCallback(activity);
+    }
+
+    @Override
+    public @NonNull ActivityPanel createActivityPanel(
+            @NonNull Activity host, @NonNull ActivityPanelLaunchParameters launchParameters) {
+        return SpaceTypeConverter.toLibrary(
+                mExtensions.createActivityPanel(
+                        host, SpaceTypeConverter.toFramework(launchParameters)));
+    }
+
+    @Override
+    @Deprecated
+    public boolean canEmbedActivityPanel(@NonNull Activity activity) {
+        // TODO(coderleon): update the doc when we support spatial task fragment.
+        return mExtensions.canEmbedActivityPanel(activity);
+    }
+
+    @Override
+    @Deprecated
+    public boolean requestFullSpaceMode(@NonNull Activity activity) {
+        return mExtensions.requestFullSpaceMode(activity);
+    }
+
+    @Override
+    @Deprecated
+    public boolean requestHomeSpaceMode(@NonNull Activity activity) {
+        return mExtensions.requestHomeSpaceMode(activity);
+    }
+
+    @Override
+    public void requestFullSpaceMode(
+            @NonNull Activity activity,
+            boolean requestEnter,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.requestFullSpaceMode(
+                activity,
+                requestEnter,
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    public @NonNull Bundle setFullSpaceMode(@NonNull Bundle bundle) {
+        return mExtensions.setFullSpaceStartMode(bundle);
+    }
+
+    @Override
+    public @NonNull Bundle setFullSpaceModeWithEnvironmentInherited(@NonNull Bundle bundle) {
+        return mExtensions.setFullSpaceStartModeWithEnvironmentInherited(bundle);
+    }
+
+    @Override
+    @Deprecated
+    public @NonNull Bundle setMainPanelCurvatureRadius(
+            @NonNull Bundle bundle, float panelCurvatureRadius) {
+        return mExtensions.setMainPanelCurvatureRadius(bundle, panelCurvatureRadius);
+    }
+
+    @Override
+    public @NonNull Config getConfig() {
+        return new ConfigImpl(mExtensions.getConfig());
+    }
+
+    @Override
+    public void hitTest(
+            @NonNull Activity activity,
+            @NonNull Vec3 origin,
+            @NonNull Vec3 direction,
+            @NonNull Consumer<HitTestResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.hitTest(
+                activity,
+                NodeTypeConverter.toFramework(origin),
+                NodeTypeConverter.toFramework(direction),
+                /* callback= */ (result) -> {
+                    callback.accept(SpaceTypeConverter.toLibrary(result));
+                },
+                executor);
+    }
+
+    @Override
+    public int getOpenXrWorldSpaceType() {
+        return mExtensions.getOpenXrWorldReferenceSpaceType();
+    }
+
+    @Override
+    public @NonNull ReformOptions createReformOptions(
+            @NonNull Consumer<ReformEvent> callback, @NonNull Executor executor) {
+        return NodeTypeConverter.toLibrary(
+                mExtensions.createReformOptions(
+                        /* callback= */ (event) ->
+                                callback.accept(NodeTypeConverter.toLibrary(event)),
+                        executor));
+    }
+
+    @Override
+    public void addFindableView(@NonNull View view, @NonNull ViewGroup group) {
+        mExtensions.addFindableView(view, group);
+    }
+
+    @Override
+    public void removeFindableView(@NonNull View view, @NonNull ViewGroup group) {
+        mExtensions.removeFindableView(view, group);
+    }
+
+    @Override
+    public @Nullable Node getSurfaceTrackingNode(@NonNull View view) {
+        return NodeTypeConverter.toLibrary(mExtensions.getSurfaceTrackingNode(view));
+    }
+
+    @Override
+    @Deprecated
+    public void setPreferredAspectRatio(@NonNull Activity activity, float preferredRatio) {
+        mExtensions.setPreferredAspectRatio(activity, preferredRatio);
+    }
+
+    @Override
+    public void setPreferredAspectRatio(
+            @NonNull Activity activity,
+            float preferredRatio,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor) {
+        mExtensions.setPreferredAspectRatio(
+                activity,
+                preferredRatio,
+                /* callback= */ (result) -> {
+                    callback.accept(new XrExtensionResultImpl(result));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void getSpatialCapabilities(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialCapabilities> callback,
+            @NonNull Executor executor) {
+        mExtensions.getSpatialCapabilities(
+                activity,
+                /* callback= */ (capabilities) -> {
+                    callback.accept(SpaceTypeConverter.toLibrary(capabilities));
+                },
+                executor);
+    }
+
+    @Override
+    @Deprecated
+    public void getBounds(
+            @NonNull Activity activity,
+            @NonNull Consumer<Bounds> callback,
+            @NonNull Executor executor) {
+        mExtensions.getBounds(
+                activity,
+                /* callback= */ (bounds) -> {
+                    callback.accept(SpaceTypeConverter.toLibrary(bounds));
+                },
+                executor);
+    }
+
+    @Override
+    public @NonNull SpatialState getSpatialState(@NonNull Activity activity) {
+        return SpaceTypeConverter.toLibrary(mExtensions.getSpatialState(activity));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/Config.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/Config.java
new file mode 100644
index 0000000..2ab5571
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/Config.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import androidx.annotation.RestrictTo;
+
+/** XR configuration information. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Config {
+    /**
+     * Returns the default pixelsPerMeter value for 2D surfaces. See
+     * NodeTransaction.setPixelResolution() for the meaning of pixelsPerMeter.
+     *
+     * @param density The logical density of the display.
+     * @return The default pixelsPerMeter value.
+     */
+    float defaultPixelsPerMeter(float density);
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ConfigImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ConfigImpl.java
new file mode 100644
index 0000000..ad32052
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ConfigImpl.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+class ConfigImpl implements Config {
+    @NonNull private final com.android.extensions.xr.Config mConfig;
+
+    ConfigImpl(@NonNull com.android.extensions.xr.Config config) {
+        requireNonNull(config);
+
+        this.mConfig = config;
+    }
+
+    @Override
+    public float defaultPixelsPerMeter(float density) {
+        return mConfig.defaultPixelsPerMeter(density);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ExperimentalExtensionApi.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ExperimentalExtensionApi.java
new file mode 100644
index 0000000..3d64195
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/ExperimentalExtensionApi.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.CLASS;
+
+import androidx.annotation.RequiresOptIn;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation denotes an experimental Extensions API.
+ *
+ * <p>There are no guarantees on stability of these APIs; they may change over time. By opting-in,
+ * users of these APIs assume the risk of breakage as these changes occur.
+ */
+@Retention(CLASS)
+@Target({TYPE, METHOD, CONSTRUCTOR, FIELD})
+@RequiresOptIn(level = RequiresOptIn.Level.ERROR)
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public @interface ExperimentalExtensionApi {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/IBinderWrapper.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/IBinderWrapper.java
new file mode 100644
index 0000000..f6012ce
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/IBinderWrapper.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+import java.util.Objects;
+
+/** A wrapper class for {@link IBinder}. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class IBinderWrapper {
+
+    private final IBinder mToken;
+
+    public IBinderWrapper(@NonNull IBinder token) {
+        mToken = token;
+    }
+
+    @NonNull
+    protected IBinder getRawToken() {
+        return mToken;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof IBinderWrapper)) return false;
+        IBinderWrapper token = (IBinderWrapper) o;
+        return Objects.equals(mToken, token.mToken);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mToken);
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return "Token{" + "mToken=" + mToken + '}';
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResult.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResult.java
new file mode 100644
index 0000000..00a80a6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResult.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Represents a result of an asynchronous XR Extension call. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface XrExtensionResult {
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                XR_RESULT_SUCCESS,
+                XR_RESULT_SUCCESS_NOT_VISIBLE,
+                XR_RESULT_IGNORED_ALREADY_APPLIED,
+                XR_RESULT_ERROR_INVALID_STATE,
+                XR_RESULT_ERROR_NOT_ALLOWED,
+                XR_RESULT_ERROR_IGNORED,
+                XR_RESULT_ERROR_SYSTEM,
+            })
+    @Retention(SOURCE)
+    public @interface ResultType {}
+
+    /**
+     * The asynchronous call has been accepted by the system service, and an immediate state change
+     * is expected.
+     */
+    int XR_RESULT_SUCCESS = 0;
+
+    /**
+     * The asynchronous call has been accepted by the system service, but the caller activity's
+     * spatial state won't be changed until other condition(s) are met.
+     */
+    int XR_RESULT_SUCCESS_NOT_VISIBLE = 1;
+
+    /**
+     * The asynchronous call has been ignored by the system service because the caller activity is
+     * already in the requested state.
+     */
+    int XR_RESULT_IGNORED_ALREADY_APPLIED = 2;
+
+    /**
+     * @deprecated Renamed. Use XR_RESULT_IGNORED_ALREADY_APPLIED.
+     */
+    @Deprecated int XR_RESULT_ERROR_INVALID_STATE = 2;
+
+    /**
+     * The asynchronous call has been rejected by the system service because the caller activity
+     * does not have the required capability.
+     */
+    int XR_RESULT_ERROR_NOT_ALLOWED = 3;
+
+    /**
+     * @deprecated Renamed. Use XR_RESULT_ERROR_NOT_ALLOWED.
+     */
+    @Deprecated int XR_RESULT_ERROR_IGNORED = 3;
+
+    /**
+     * The asynchronous call cannot be sent to the system service, or the service cannot properly
+     * handle the request. This is not a recoverable error for the client. For example, this error
+     * is sent to the client when an asynchronous call attempt has failed with a RemoteException.
+     */
+    int XR_RESULT_ERROR_SYSTEM = 4;
+
+    /** Returns the result. */
+    default @ResultType int getResult() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResultImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResultImpl.java
new file mode 100644
index 0000000..59ae0bb
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionResultImpl.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import android.util.Log;
+
+class XrExtensionResultImpl implements XrExtensionResult {
+    private static final String TAG = "XrExtensionResultImpl";
+    private final @ResultType int result;
+
+    XrExtensionResultImpl(com.android.extensions.xr.XrExtensionResult result) {
+        switch (result.getResult()) {
+            case com.android.extensions.xr.XrExtensionResult.XR_RESULT_SUCCESS:
+                this.result = XrExtensionResult.XR_RESULT_SUCCESS;
+                break;
+            case com.android.extensions.xr.XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE:
+                this.result = XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE;
+                break;
+            case com.android.extensions.xr.XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED:
+                this.result = XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED;
+                break;
+            case com.android.extensions.xr.XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED:
+                this.result = XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED;
+                break;
+            case com.android.extensions.xr.XrExtensionResult.XR_RESULT_ERROR_SYSTEM:
+                this.result = XrExtensionResult.XR_RESULT_ERROR_SYSTEM;
+                break;
+            default:
+                // This path should never be taken.
+                Log.wtf(TAG, "Unknown result: " + result);
+                this.result = XrExtensionResult.XR_RESULT_ERROR_SYSTEM;
+                break;
+        }
+    }
+
+    @Override
+    public @ResultType int getResult() {
+        return result;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensions.java
new file mode 100644
index 0000000..1417ff4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensions.java
@@ -0,0 +1,825 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.asset.EnvironmentToken;
+import androidx.xr.extensions.asset.GltfModelToken;
+import androidx.xr.extensions.asset.SceneToken;
+import androidx.xr.extensions.media.XrSpatialAudioExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.extensions.space.ActivityPanel;
+import androidx.xr.extensions.space.ActivityPanelLaunchParameters;
+import androidx.xr.extensions.space.Bounds;
+import androidx.xr.extensions.space.HitTestResult;
+import androidx.xr.extensions.space.SpatialCapabilities;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.extensions.space.SpatialStateEvent;
+import androidx.xr.extensions.splitengine.SplitEngineBridge;
+import androidx.xr.extensions.subspace.Subspace;
+
+import java.io.InputStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+
+/**
+ * The main extensions class that creates or provides instances of various XR Extensions components.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface XrExtensions {
+    String IMAGE_TOO_OLD =
+            "This device's system image doesn't include the necessary "
+                    + "implementation for this API. Please update to the latest system image. "
+                    + "This API requires a corresponding implementation on the device to function "
+                    + "correctly.";
+
+    /** Get the current version of the {@link XrExtensions} API. */
+    int getApiVersion();
+
+    /**
+     * Synchronously creates a node that can host a 2D panel or 3D subspace.
+     *
+     * @return A {@link Node}.
+     */
+    @NonNull
+    Node createNode();
+
+    /**
+     * Synchronously creates a new transaction that can be used to update multiple {@link Node}'s
+     * data and transformation in the 3D space.
+     *
+     * @return A {@link NodeTransaction} that can be used to queue the updates and submit to backend
+     *     at once.
+     */
+    @NonNull
+    NodeTransaction createNodeTransaction();
+
+    /**
+     * Synchronously creates a subspace.
+     *
+     * @param splitEngineBridge The splitEngineBridge.
+     * @param subspaceId The unique identifier of the subspace.
+     * @return A {@link Subspace} that can be used to render 3D content in.
+     */
+    @NonNull
+    Subspace createSubspace(@NonNull SplitEngineBridge splitEngineBridge, int subspaceId);
+
+    /**
+     * Loads and caches the glTF model in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the glTF model.
+     * @param regionSizeBytes The size of the memory region where the model is stored (in bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded. This string is only used for caching purposes.
+     * @return A {@link CompletableFuture} that either contains the {@link GltfModelToken}
+     *     representing the loaded model or 'null' if the asset could not be loaded successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @NonNull
+    CompletableFuture</* @Nullable */ GltfModelToken> loadGltfModel(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes, String url);
+
+    /**
+     * Views a 3D asset.
+     *
+     * @param activity The activity which relinquishes control in order to display the model..
+     * @param gltfModel The model to display.
+     * @return A {@link CompletableFuture} that notifies the caller when the session has completed.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @NonNull
+    CompletableFuture</* @Nullable */ SceneViewerResult> displayGltfModel(
+            Activity activity, GltfModelToken gltfModel);
+
+    /**
+     * Loads and caches the environment in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the EXR or JPEG environment.
+     * @param regionSizeBytes The size of the memory region where the environment is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded. This string is only used for caching purposes.
+     * @return A {@link CompletableFuture} that either contains the {@link EnvironmentToken}
+     *     representing the loaded environment or 'null' if the asset could not be loaded
+     *     successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @NonNull
+    CompletableFuture</* @Nullable */ EnvironmentToken> loadEnvironment(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes, String url);
+
+    /**
+     * Loads and caches the environment in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the EXR or JPEG environment.
+     * @param regionSizeBytes The size of the memory region where the environment is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded.
+     * @param textureWidth The target width of the final texture which will be downsampled/upsampled
+     *     from the original image.
+     * @param textureHeight The target height of the final texture which will be
+     *     downsampled/upsampled from the original image.
+     * @return A {@link CompletableFuture} that either contains the {@link EnvironmentToken}
+     *     representing the loaded environment or 'null' if the asset could not be loaded
+     *     successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @NonNull
+    CompletableFuture</* @Nullable */ EnvironmentToken> loadEnvironment(
+            InputStream asset,
+            int regionSizeBytes,
+            int regionOffsetBytes,
+            String url,
+            int textureWidth,
+            int textureHeight);
+
+    /**
+     * Loads and caches the Impress scene in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the textproto Impress scene.
+     * @param regionSizeBytes The size of the memory region where the Impress scene is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @return A {@link CompletableFuture} that either contains the {@link SceneToken} representing
+     *     the loaded Impress scene or 'null' if the asset could not be loaded successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @NonNull
+    CompletableFuture</* @Nullable */ SceneToken> loadImpressScene(
+            InputStream asset, int regionSizeBytes, int regionOffsetBytes);
+
+    /**
+     * Synchronously returns a {@link SplitEngineBridge}.
+     *
+     * @return A {@link SplitEngineBridge}.
+     */
+    @NonNull
+    SplitEngineBridge createSplitEngineBridge();
+
+    /**
+     * Synchronously returns the implementation of the {@link XrSpatialAudioExtensions} component.
+     *
+     * @return The {@link XrSpatialAudioExtensions}.
+     */
+    @NonNull
+    XrSpatialAudioExtensions getXrSpatialAudioExtensions();
+
+    /**
+     * Attaches the given {@code sceneNode} as the presentation for the given {@code activity} in
+     * the space, and asks the system to attach the 2D content of the {@code activity} into the
+     * given {@code windowNode}.
+     *
+     * <p>The {@code sceneNode} will only be visible if the {@code activity} is visible as in a
+     * lifecycle state between {@link Activity#onStart()} and {@link Activity#onStop()} and is
+     * SPATIAL_UI_CAPABLE too.
+     *
+     * <p>One activity can only attach one scene node. When a new scene node is attached for the
+     * same {@code activity}, the previous one will be detached.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param sceneNode the node to show as the presentation of the {@code activity}.
+     * @param windowNode a leash node to allow the app to control the position and size of the
+     *     activity's main window.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void attachSpatialScene(
+            @NonNull Activity activity, @NonNull Node sceneNode, @NonNull Node windowNode);
+
+    /**
+     * Attaches the given {@code sceneNode} as the presentation for the given {@code activity} in
+     * the space, and asks the system to attach the 2D content of the {@code activity} into the
+     * given {@code windowNode}.
+     *
+     * <p>The {@code sceneNode} will only be visible if the {@code activity} is visible as in a
+     * lifecycle state between {@link Activity#onStart()} and {@link Activity#onStop()} and is
+     * SPATIAL_UI_CAPABLE too.
+     *
+     * <p>One activity can only attach one scene node. When a new scene node is attached for the
+     * same {@code activity}, the previous one will be detached.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param sceneNode the node to show as the presentation of the {@code activity}.
+     * @param windowNode a leash node to allow the app to control the position and size of the
+     *     activity's main window.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void attachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Node sceneNode,
+            @NonNull Node windowNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Detaches the {@code sceneNode} that was previously attached for the {@code activity} via
+     * {@link #attachSpatialScene}.
+     *
+     * <p>When an {@link Activity} is destroyed, it must call this method to detach the scene node
+     * that was attached for itself.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void detachSpatialScene(@NonNull Activity activity);
+
+    /**
+     * Detaches the {@code sceneNode} that was previously attached for the {@code activity} via
+     * {@link #attachSpatialScene}.
+     *
+     * <p>When an {@link Activity} is destroyed, it must call this method to detach the scene node
+     * that was attached for itself.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void detachSpatialScene(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Resizes the main window of the given activity to the requested size.
+     *
+     * @param activity the activity whose main window should be resized.
+     * @param width the new main window width in pixels.
+     * @param height the new main window height in pixels.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void setMainWindowSize(@NonNull Activity activity, int width, int height);
+
+    /**
+     * Resizes the main window of the given activity to the requested size.
+     *
+     * @param activity the activity whose main window should be resized.
+     * @param width the new main window width in pixels.
+     * @param height the new main window height in pixels.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void setMainWindowSize(
+            @NonNull Activity activity,
+            int width,
+            int height,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Sets the main window of the given activity to the curvature radius. Note that it's allowed
+     * only for the activity in full space mode.
+     *
+     * @param activity the activity of the main window to which the curvature should be applied.
+     * @param curvatureRadius the panel curvature radius. It is measured in "radius * 1 /
+     *     curvature". A value of 0.0f means that the panel will be flat.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    void setMainWindowCurvatureRadius(@NonNull Activity activity, float curvatureRadius);
+
+    /**
+     * Attaches an environment node for a given activity to make it visible.
+     *
+     * <p>SysUI will attach the environment node to the task node when the activity gains the
+     * APP_ENVIRONMENTS_CAPABLE capability.
+     *
+     * <p>This method can be called multiple times, SysUI will attach the new environment node and
+     * detach the old environment node if it exists.
+     *
+     * @param activity the activity that provides the environment node to attach.
+     * @param environmentNode the environment node provided by the activity to be attached.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void attachSpatialEnvironment(@NonNull Activity activity, @NonNull Node environmentNode);
+
+    /**
+     * Attaches an environment node for a given activity to make it visible.
+     *
+     * <p>SysUI will attach the environment node to the task node when the activity gains the
+     * APP_ENVIRONMENTS_CAPABLE capability.
+     *
+     * <p>This method can be called multiple times, SysUI will attach the new environment node and
+     * detach the old environment node if it exists.
+     *
+     * <p>Note that once an environmentNode is attached and the caller gains
+     * APP_ENVIRONMENTS_CAPABLE capability, spatial callback's environment visibility status changes
+     * to APP_VISIBLE even if your application hasn't set a skybox or geometry to the environment
+     * node yet. For that reason, call this API only when your application wants to show a skybox or
+     * geometry. Otherwise, the APP_VISIBLE spatial state may lead to an unexpected behavior. For
+     * example, home environment's ambient audio (if any) may stop even if the user can still see
+     * the home environment.
+     *
+     * @param activity the activity that provides the environment node to attach.
+     * @param environmentNode the environment node provided by the activity to be attached.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     APP_ENVIRONMENTS_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void attachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Node environmentNode,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Detaches the environment node and its sub tree for a given activity to make it invisible.
+     *
+     * <p>This method will detach and cleanup the environment node and its subtree passed from the
+     * activity.
+     *
+     * @param activity the activity with which SysUI will detach and clean up the environment node
+     *     tree.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void detachSpatialEnvironment(@NonNull Activity activity);
+
+    /**
+     * Detaches the environment node and its sub tree for a given activity to make it invisible.
+     *
+     * <p>This method will detach and cleanup the environment node and its subtree passed from the
+     * activity.
+     *
+     * @param activity the activity with which SysUI will detach and clean up the environment node
+     *     tree.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     APP_ENVIRONMENTS_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void detachSpatialEnvironment(
+            @NonNull Activity activity,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Sets a callback to receive {@link SpatialStateEvent} for the given {@code activity}.
+     *
+     * <p>One activity can only set one callback. When a new callback is set for the same {@code
+     * activity}, the previous one will be cleared.
+     *
+     * <p>The callback will be triggered immediately with the current state when it is set, for each
+     * of the possible events.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     * @param callback the callback to set.
+     * @param executor the executor that the callback will be called on.
+     * @see #clearSpatialStateCallback
+     * @deprecated Use registerSpatialStateCallback instead.
+     */
+    @Deprecated
+    void setSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialStateEvent> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Synchronously registers a callback to receive {@link SpatialState} for the {@code activity}.
+     *
+     * <p>One activity can only set one callback. When a new callback is set for the same {@code
+     * activity}, the previous one will be cleared.
+     *
+     * <p>The {@code executor}'s execute() method will soon be called to run the callback with the
+     * current state when it is available, but it never happens directly from within this call.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     * @param callback the callback to set.
+     * @param executor the executor that the callback will be called on.
+     * @see #clearSpatialStateCallback
+     */
+    void registerSpatialStateCallback(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialState> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Synchronously clears the {@link SpatialStateEvent} callback that was previously set to the
+     * {@code activity} via {@link #setSpatialStateCallback}.
+     *
+     * <p>When an {@link Activity} is destroyed, it must call this method to clear the callback that
+     * was set for itself.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     */
+    void clearSpatialStateCallback(@NonNull Activity activity);
+
+    /**
+     * Synchronously creates an {@link ActivityPanel} to be embedded inside the given {@code host}
+     * activity.
+     *
+     * <p>Caller must make sure the {@code host} can embed {@link ActivityPanel}. See {@link
+     * getSpatialState()}. When embedding is possible, SpatialState's {@link SpatialCapabilities}
+     * has {@code SPATIAL_ACTIVITY_EMBEDDING_CAPABLE}.
+     *
+     * <p>For the {@link ActivityPanel} to be shown in the scene, caller needs to attach the {@link
+     * ActivityPanel#getNode()} to the scene node attached through {@link #attachSpatialScene}.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity.
+     *
+     * @param host the host activity to embed the {@link ActivityPanel}.
+     * @param launchParameters the parameters to define the initial state of the {@link
+     *     ActivityPanel}.
+     * @return the {@link ActivityPanel} created.
+     * @throws IllegalStateException if the {@code host} is not allowed to embed {@link
+     *     ActivityPanel}.
+     */
+    @NonNull
+    ActivityPanel createActivityPanel(
+            @NonNull Activity host, @NonNull ActivityPanelLaunchParameters launchParameters);
+
+    /**
+     * Synchronously checks if an activity can be the host to embed an {@link ActivityPanel}.
+     *
+     * <p>Activity inside an {@link ActivityPanel} cannot be the host.
+     *
+     * @param activity the activity to check.
+     * @see #createActivityPanel
+     * @return true if the embedding is allowed.
+     * @deprecated Use {@link getSpatialState()} instead. When embedding is possible, SpatialState's
+     *     {@link SpatialCapabilities} has {@code SPATIAL_ACTIVITY_EMBEDDING_CAPABLE}.
+     */
+    @Deprecated
+    boolean canEmbedActivityPanel(@NonNull Activity activity);
+
+    /**
+     * Requests to put an activity in full space mode when it has focus.
+     *
+     * @param activity the activity that requires to enter full space mode.
+     * @return true when the request was sent (when the activity has focus).
+     * @deprecated Use requestFullSpaceMode with 3 arguments.
+     */
+    @Deprecated
+    boolean requestFullSpaceMode(@NonNull Activity activity);
+
+    /**
+     * Requests to put an activity in home space mode when it has focus.
+     *
+     * @param activity the activity that requires to enter home space mode.
+     * @return true when the request was sent (when the activity has focus).
+     * @deprecated Use requestFullSpaceMode with 3 arguments.
+     */
+    @Deprecated
+    boolean requestHomeSpaceMode(@NonNull Activity activity);
+
+    /**
+     * Requests to put an activity in a different mode when it has focus.
+     *
+     * @param activity the activity that requires to enter full space mode.
+     * @param requestEnter when true, activity is put in full space mode. Home space mode otherwise.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested mode.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. not the top activity in a top task
+     *     in the desktop, called by an embedded guest activity.)
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void requestFullSpaceMode(
+            @NonNull Activity activity,
+            boolean requestEnter,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Synchronously sets the full space mode flag to the given {@link Bundle}.
+     *
+     * <p>The {@link Bundle} then could be used to launch an {@link Activity} with requesting to
+     * enter full space mode through {@link Activity#startActivity}. If there's a bundle used for
+     * customizing how the {@link Activity} should be started by {@link ActivityOptions.toBundle} or
+     * {@link androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to
+     * call this method.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * bundle, or it is not started from a focused Activity context.
+     *
+     * <p>This flag is also ignored when the {@link android.window.PROPERTY_XR_ACTIVITY_START_MODE}
+     * property is set to a value other than XR_ACTIVITY_START_MODE_UNDEFINED in the
+     * AndroidManifest.xml file for the activity being launched.
+     *
+     * @param bundle the input bundle to set with the full space mode flag.
+     * @return the input {@code bundle} with the full space mode flag set.
+     */
+    @NonNull
+    Bundle setFullSpaceMode(@NonNull Bundle bundle);
+
+    /**
+     * Synchronously sets the inherit full space mode environvment flag to the given {@link Bundle}.
+     *
+     * <p>The {@link Bundle} then could be used to launch an {@link Activity} with requesting to
+     * enter full space mode while inherit the existing environment through {@link
+     * Activity#startActivity}. If there's a bundle used for customizing how the {@link Activity}
+     * should be started by {@link ActivityOptions.toBundle} or {@link
+     * androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to call
+     * this method.
+     *
+     * <p>When launched, the activity will be in full space mode and also inherits the environment
+     * from the launching activity. If the inherited environment needs to be animated, the launching
+     * activity has to continue updating the environment even after the activity is put into the
+     * stopped state.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * intent, or it is not started from a focused Activity context.
+     *
+     * <p>The flag will also be ignored when there is no environment to inherit or the activity has
+     * its own environment set already.
+     *
+     * <p>This flag is ignored too when the {@link android.window.PROPERTY_XR_ACTIVITY_START_MODE}
+     * property is set to a value other than XR_ACTIVITY_START_MODE_UNDEFINED in the
+     * AndroidManifest.xml file for the activity being launched.
+     *
+     * <p>For security reasons, Z testing for the new activity is disabled, and the activity is
+     * always drawn on top of the inherited environment. Because Z testing is disabled, the activity
+     * should not spatialize itself, and should not curve its panel too much either.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @return the input {@code bundle} with the inherit full space mode flag set.
+     */
+    @NonNull
+    Bundle setFullSpaceModeWithEnvironmentInherited(@NonNull Bundle bundle);
+
+    /**
+     * Sets panel curvature radius to the given {@link Bundle}.
+     *
+     * <p>The {@link Bundle} then could be used to launch an {@link Activity} with requesting to a
+     * custom curvature radius for the main panel through {@link Activity#startActivity}. If there's
+     * a bundle used for customizing how the {@link Activity} should be started by {@link
+     * ActivityOptions.toBundle} or {@link androidx.core.app.ActivityOptionsCompat.toBundle}, it's
+     * suggested to use the bundle to call this method.
+     *
+     * <p>The curvature radius must be used together with {@link
+     * #setFullSpaceModeWithEnvironmentInherited(Bundle)}. Otherwise, it will be ignored.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @param panelCurvatureRadius the panel curvature radius. It is measured in "radius * 1 /
+     *     curvature". A value of 0.0f means the panel is flat.
+     * @return the input {@code bundle} with the inherit full space mode flag set.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    @NonNull
+    Bundle setMainPanelCurvatureRadius(@NonNull Bundle bundle, float panelCurvatureRadius);
+
+    /**
+     * Synchronously returns system config information.
+     *
+     * @return A {@link Config} object.
+     */
+    @NonNull
+    Config getConfig();
+
+    /**
+     * Hit-tests a ray against the virtual scene. If the ray hits an object in the scene,
+     * information about the hit will be passed to the callback. If nothing is hit, the hit distance
+     * will be infinite. Note that attachSpatialScene() must be called before calling this method.
+     * Otherwise, an IllegalArgumentException is thrown.
+     *
+     * @param activity the requesting activity.
+     * @param origin the origin of the ray to test, in the activity's task coordinates.
+     * @param direction the direction of the ray to test, in the activity's task coordinates.
+     * @param callback the callback that will be called with the hit test result.
+     * @param executor the executor the callback will be called on.
+     */
+    void hitTest(
+            @NonNull Activity activity,
+            @NonNull Vec3 origin,
+            @NonNull Vec3 direction,
+            @NonNull Consumer<HitTestResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Synchronously returns the OpenXR reference space type.
+     *
+     * @return the OpenXR reference space type used as world space for the shared scene.
+     */
+    int getOpenXrWorldSpaceType();
+
+    /**
+     * Synchronously creates a new ReformOptions instance.
+     *
+     * @param callback the callback that will be called with reform events.
+     * @param executor the executor the callback will be called on.
+     * @return the new builder instance.
+     */
+    @NonNull
+    ReformOptions createReformOptions(
+            @NonNull Consumer<ReformEvent> callback, @NonNull Executor executor);
+
+    /**
+     * Synchronously makes a View findable via findViewById().
+     *
+     * <p>This is done without it being a child of the given group.
+     *
+     * @param view the view to add as findable.
+     * @param group a group that is part of the hierarchy that findViewById() will be called on.
+     */
+    void addFindableView(@NonNull View view, @NonNull ViewGroup group);
+
+    /**
+     * Synchronously removes a findable view from the given group.
+     *
+     * @param view the view to remove as findable.
+     * @param group the group to remove the findable view from.
+     */
+    void removeFindableView(@NonNull View view, @NonNull ViewGroup group);
+
+    /**
+     * Returns the surface tracking node for a view, if there is one.
+     *
+     * <p>The surface tracking node is centered on the Surface that the view is attached to, and is
+     * sized to match the surface's size. Note that the view's position in the surface can be
+     * retrieved via View.getLocationInSurface().
+     *
+     * @param view the view.
+     * @return the surface tracking node, or null if no such node exists.
+     */
+    @Nullable
+    Node getSurfaceTrackingNode(@NonNull View view);
+
+    /**
+     * Sets a preferred main panel aspect ratio for home space mode.
+     *
+     * <p>The ratio is only applied to the activity. If the activity launches another activity in
+     * the same task, the ratio is not applied to the new activity. Also, while the activity is in
+     * full space mode, the preference is temporarily removed.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *     height. A value <= 0.0f means there are no preferences.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    void setPreferredAspectRatio(@NonNull Activity activity, float preferredRatio);
+
+    /**
+     * Sets a preferred main panel aspect ratio for an activity that is not SPATIAL_UI_CAPABLE.
+     *
+     * <p>The ratio is only applied to the activity. If the activity launches another activity in
+     * the same task, the ratio is not applied to the new activity. Also, while the activity is
+     * SPATIAL_UI_CAPABLE, the preference is temporarily removed. While the activity is
+     * SPATIAL_UI_CAPABLE, use ReformOptions API instead.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *     height. A value <= 0.0f means there are no preferences.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity loses the
+     *     SPATIAL_UI_CAPABLE capability. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    void setPreferredAspectRatio(
+            @NonNull Activity activity,
+            float preferredRatio,
+            @NonNull Consumer<XrExtensionResult> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Gets the spatial capabilities of the activity.
+     *
+     * @param activity the activity to get the capabilities.
+     * @param callback the callback to run. If the activity is not found in SysUI, the callback runs
+     *     with a null SpatialCapabilities.
+     * @param executor the executor that the callback will be called on.
+     * @deprecated Use getSpatialState synchronous getter.
+     */
+    @Deprecated
+    void getSpatialCapabilities(
+            @NonNull Activity activity,
+            @NonNull Consumer<SpatialCapabilities> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Gets the bounds of the activity.
+     *
+     * @param activity the activity to get the bounds.
+     * @param callback the callback to run. If the activity is not found in SysUI, the callback runs
+     *     with a null Bounds.
+     * @param executor the executor that the callback will be called on.
+     * @deprecated Use getSpatialState synchronous getter.
+     */
+    @Deprecated
+    void getBounds(
+            @NonNull Activity activity,
+            @NonNull Consumer<Bounds> callback,
+            @NonNull Executor executor);
+
+    /**
+     * Synchronously gets the spatial state of the activity.
+     *
+     * <p>Do not call the API from the Binder thread. That may cause a deadlock.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity,
+     * and also throws RuntimeException if the calling thread is interrupted.
+     *
+     * @param activity the activity to get the capabilities.
+     * @return the state of the activity.
+     */
+    @NonNull
+    SpatialState getSpatialState(@NonNull Activity activity);
+
+    /**
+     * The result of a displayGltfModel request.
+     *
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    class SceneViewerResult {}
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionsProvider.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionsProvider.java
new file mode 100644
index 0000000..d2580f1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/XrExtensionsProvider.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+/** Provides the OEM implementation of {@link XrExtensions}. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class XrExtensionsProvider {
+    private XrExtensionsProvider() {}
+
+    /**
+     * Returns the OEM implementation of {@link XrExtensions}.
+     *
+     * @return The OEM implementation of {@link XrExtensions} or throws an exception if no
+     *     implementation is found.
+     */
+    @Nullable
+    public static XrExtensions getXrExtensions() {
+        try {
+            return XrExtensionsInstance.getInstance();
+        } catch (NoClassDefFoundError e) {
+            return null;
+        }
+    }
+
+    private static class XrExtensionsInstance {
+        private XrExtensionsInstance() {}
+
+        private static class XrExtensionsHolder {
+            public static final XrExtensions INSTANCE =
+                    new AndroidXrExtensions(new com.android.extensions.xr.XrExtensions());
+        }
+
+        public static XrExtensions getInstance() {
+            return XrExtensionsHolder.INSTANCE;
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetToken.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetToken.java
new file mode 100644
index 0000000..20546b1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetToken.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a spatial asset cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link AssetToken} such that the SpaceFlinger will render the asset
+ * inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link AssetToken} so that it can continue using
+ * it, and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface AssetToken {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetTokenImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetTokenImpl.java
new file mode 100644
index 0000000..ed23553
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/AssetTokenImpl.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+@Deprecated
+class AssetTokenImpl implements AssetToken {
+    @NonNull final com.android.extensions.xr.asset.AssetToken mToken;
+
+    AssetTokenImpl(@NonNull com.android.extensions.xr.asset.AssetToken token) {
+        requireNonNull(token);
+        this.mToken = token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentToken.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentToken.java
new file mode 100644
index 0000000..8efd689
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentToken.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of an environment cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link EnvironmentToken} such that the SpaceFlinger will render the
+ * asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link EnvironmentToken} so that it can continue
+ * using it, and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface EnvironmentToken extends AssetToken {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentTokenImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentTokenImpl.java
new file mode 100644
index 0000000..3d41c4d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/EnvironmentTokenImpl.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+@Deprecated
+class EnvironmentTokenImpl implements EnvironmentToken {
+    @NonNull final com.android.extensions.xr.asset.EnvironmentToken mToken;
+
+    EnvironmentTokenImpl(@NonNull com.android.extensions.xr.asset.EnvironmentToken token) {
+        requireNonNull(token);
+        mToken = token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfAnimation.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfAnimation.java
new file mode 100644
index 0000000..a472c68
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfAnimation.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Animation configuration to be played on a glTF model.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface GltfAnimation {
+    /** State of a glTF animation. */
+    enum State {
+        /** Will stop the glTF animation that is currently playing or looping. */
+        STOP,
+        /** Will restart the glTF animation if it's currently playing, looping, or is stopped. */
+        PLAY,
+        /**
+         * Will restart and loop the glTF animation if it's currently playing, looping, or is
+         * stopped.
+         */
+        LOOP
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelToken.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelToken.java
new file mode 100644
index 0000000..1f2081e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelToken.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a glTF model cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link GltfModelToken} such that the SpaceFlinger will render the
+ * asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link GltfModelToken} so that it can continue
+ * using it, and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface GltfModelToken extends AssetToken {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelTokenImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelTokenImpl.java
new file mode 100644
index 0000000..83521ef
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/GltfModelTokenImpl.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+@Deprecated
+class GltfModelTokenImpl implements GltfModelToken {
+    @NonNull final com.android.extensions.xr.asset.GltfModelToken mToken;
+
+    GltfModelTokenImpl(@NonNull com.android.extensions.xr.asset.GltfModelToken token) {
+        requireNonNull(token);
+        mToken = token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneToken.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneToken.java
new file mode 100644
index 0000000..b5da782
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneToken.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a scene cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link SceneToken} such that the SpaceFlinger will render the asset
+ * inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link SceneToken} so that it can continue using
+ * it, and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SceneToken extends AssetToken {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneTokenImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneTokenImpl.java
new file mode 100644
index 0000000..9b37a6c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/SceneTokenImpl.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+@Deprecated
+class SceneTokenImpl implements SceneToken {
+    @NonNull final com.android.extensions.xr.asset.SceneToken mToken;
+
+    SceneTokenImpl(@NonNull com.android.extensions.xr.asset.SceneToken token) {
+        requireNonNull(token);
+        mToken = token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/TokenConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/TokenConverter.java
new file mode 100644
index 0000000..e39a7e6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/asset/TokenConverter.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.asset;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+/**
+ * This class is able to convert library versions of {@link AssetToken}s into platform types.
+ *
+ * @deprecated This will be removed once all clients are migrated.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class TokenConverter {
+
+    private TokenConverter() {}
+
+    /**
+     * Converts a {@link GltfModelToken} to a framework type.
+     *
+     * @param token The {@link GltfModelToken} to convert.
+     * @return The framework type of the {@link GltfModelToken}.
+     * @deprecated This will be removed once all clients are migrated.
+     */
+    @Deprecated
+    @NonNull
+    public static com.android.extensions.xr.asset.GltfModelToken toFramework(
+            @NonNull GltfModelToken token) {
+        requireNonNull(token);
+
+        return ((GltfModelTokenImpl) token).mToken;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.asset.GltfModelToken} to a library type.
+     *
+     * @param token The {@link com.android.extensions.xr.asset.GltfModelToken} to convert.
+     * @return The library type of the {@link GltfModelToken}.
+     */
+    @Nullable
+    public static GltfModelToken toLibrary(
+            @Nullable com.android.extensions.xr.asset.GltfModelToken token) {
+        if (token == null) {
+            return null;
+        }
+
+        return new GltfModelTokenImpl((com.android.extensions.xr.asset.GltfModelToken) token);
+    }
+
+    /**
+     * Converts a {@link EnvironmentToken} to a framework type.
+     *
+     * @param token The {@link EnvironmentToken} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.asset.EnvironmentToken}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.asset.EnvironmentToken toFramework(
+            @NonNull EnvironmentToken token) {
+        requireNonNull(token);
+
+        return ((EnvironmentTokenImpl) token).mToken;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.asset.EnvironmentToken} to a library type.
+     *
+     * @param token The {@link com.android.extensions.xr.asset.EnvironmentToken} to convert.
+     * @return The library type of the {@link EnvironmentToken}.
+     */
+    @Nullable
+    public static EnvironmentToken toLibrary(
+            @Nullable com.android.extensions.xr.asset.EnvironmentToken token) {
+        if (token == null) {
+            return null;
+        }
+
+        return new EnvironmentTokenImpl((com.android.extensions.xr.asset.EnvironmentToken) token);
+    }
+
+    /**
+     * Converts a {@link SceneToken} to a framework type.
+     *
+     * @param token The {@link SceneToken} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.asset.SceneToken}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.asset.SceneToken toFramework(
+            @NonNull SceneToken token) {
+        requireNonNull(token);
+
+        return ((SceneTokenImpl) token).mToken;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.asset.SceneToken} to a library type.
+     *
+     * @param token The {@link com.android.extensions.xr.asset.SceneToken} to convert.
+     * @return The library type of the {@link SceneToken}.
+     */
+    @Nullable
+    public static SceneToken toLibrary(@Nullable com.android.extensions.xr.asset.SceneToken token) {
+        if (token == null) {
+            return null;
+        }
+
+        return new SceneTokenImpl((com.android.extensions.xr.asset.SceneToken) token);
+    }
+
+    /**
+     * Converts a {@link GltfAnimation.State} to a framework type.
+     *
+     * @param token The {@link GltfAnimation.State} to convert.
+     * @return The framework type {@link com.android.extensions.xr.asset.GltfAnimation.State}.
+     */
+    public static com.android.extensions.xr.asset.GltfAnimation.State toFramework(
+            GltfAnimation.State token) {
+
+        switch (token) {
+            case STOP:
+                return com.android.extensions.xr.asset.GltfAnimation.State.STOP;
+            case PLAY:
+                return com.android.extensions.xr.asset.GltfAnimation.State.PLAY;
+            case LOOP:
+                return com.android.extensions.xr.asset.GltfAnimation.State.LOOP;
+            default:
+                throw new IllegalArgumentException("Should not happen");
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentTypeConverter.java
new file mode 100644
index 0000000..147ec25
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentTypeConverter.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.environment;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class EnvironmentTypeConverter {
+
+    private EnvironmentTypeConverter() {}
+
+    /**
+     * Converts a {@link PassthroughVisibilityState} to a framework type.
+     *
+     * @param state The {@link PassthroughVisibilityState} to convert.
+     * @return The framework type of the {@link
+     *     com.android.extensions.xr.environment.PassthroughVisibilityState}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.environment.PassthroughVisibilityState toFramework(
+            @NonNull PassthroughVisibilityState state) {
+        requireNonNull(state);
+
+        return ((PassthroughVisibilityStateImpl) state).mState;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.environment.PassthroughVisibilityState} to a
+     * library type.
+     *
+     * @param state The {@link com.android.extensions.xr.environment.PassthroughVisibilityState} to
+     *     convert.
+     * @return The library type of the {@link PassthroughVisibilityState}.
+     */
+    @NonNull
+    public static PassthroughVisibilityState toLibrary(
+            @NonNull com.android.extensions.xr.environment.PassthroughVisibilityState state) {
+        requireNonNull(state);
+
+        return new PassthroughVisibilityStateImpl(state);
+    }
+
+    /**
+     * Converts a {@link EnvironmentVisibilityState} to a framework type.
+     *
+     * @param state The {@link EnvironmentVisibilityState} to convert.
+     * @return The framework type of the {@link
+     *     com.android.extensions.xr.environment.EnvironmentVisibilityState}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.environment.EnvironmentVisibilityState toFramework(
+            @NonNull EnvironmentVisibilityState state) {
+        requireNonNull(state);
+
+        return ((EnvironmentVisibilityStateImpl) state).mState;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.environment.EnvironmentVisibilityState} to a
+     * library type.
+     *
+     * @param state The {@link com.android.extensions.xr.environment.EnvironmentVisibilityState} to
+     *     convert.
+     * @return The library type of the {@link EnvironmentVisibilityState}.
+     */
+    @NonNull
+    public static EnvironmentVisibilityState toLibrary(
+            @NonNull com.android.extensions.xr.environment.EnvironmentVisibilityState state) {
+        requireNonNull(state);
+
+        return new EnvironmentVisibilityStateImpl(state);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityState.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityState.java
new file mode 100644
index 0000000..6a33d0b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityState.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.environment;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Visibility states of an environment. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface EnvironmentVisibilityState {
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                INVISIBLE,
+                HOME_VISIBLE,
+                APP_VISIBLE,
+            })
+    @Retention(SOURCE)
+    @interface State {}
+
+    /*
+     * No environment is shown. This could mean Passthrough is on with 100% opacity or the
+     * home environment app has crashed.
+     */
+    int INVISIBLE = 0;
+
+    /** Home environment is shown. Passthrough might be on but its opacity is less than 100%. */
+    int HOME_VISIBLE = 1;
+
+    /** App environment is shown. Passthrough might be on but its opacity is less than 100%. */
+    int APP_VISIBLE = 2;
+
+    /** Returns the current environment visibility state */
+    default @EnvironmentVisibilityState.State int getCurrentState() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityStateImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityStateImpl.java
new file mode 100644
index 0000000..ff90ee2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/EnvironmentVisibilityStateImpl.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.environment;
+
+import androidx.annotation.NonNull;
+
+class EnvironmentVisibilityStateImpl implements EnvironmentVisibilityState {
+    @NonNull final com.android.extensions.xr.environment.EnvironmentVisibilityState mState;
+
+    EnvironmentVisibilityStateImpl(
+            @NonNull com.android.extensions.xr.environment.EnvironmentVisibilityState state) {
+        mState = state;
+    }
+
+    @Override
+    public @EnvironmentVisibilityState.State int getCurrentState() {
+        return mState.getCurrentState();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || !(other instanceof EnvironmentVisibilityStateImpl)) {
+            return false;
+        }
+        EnvironmentVisibilityStateImpl impl = (EnvironmentVisibilityStateImpl) other;
+        return mState.equals(impl.mState);
+    }
+
+    @Override
+    public int hashCode() {
+        return mState.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return mState.toString();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityState.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityState.java
new file mode 100644
index 0000000..a1b6f98
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityState.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.environment;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Visibility states of passthrough. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface PassthroughVisibilityState {
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                DISABLED, HOME, APP, SYSTEM,
+            })
+    @Retention(SOURCE)
+    @interface State {}
+
+    /** Passthrough is not shown. */
+    int DISABLED = 0;
+
+    /** Home environment Passthrough is shown with greater than 0 opacity. */
+    int HOME = 1;
+
+    /** App set Passthrough is shown with greater than 0 opacity. */
+    int APP = 2;
+
+    /** System set Passthrough is shown with greater than 0 opacity. */
+    int SYSTEM = 3;
+
+    /** Returns the current passthrough visibility state */
+    default @PassthroughVisibilityState.State int getCurrentState() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /** Returns the current passthrough opacity */
+    default float getOpacity() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityStateImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityStateImpl.java
new file mode 100644
index 0000000..454bd51
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/environment/PassthroughVisibilityStateImpl.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.environment;
+
+import androidx.annotation.NonNull;
+
+class PassthroughVisibilityStateImpl implements PassthroughVisibilityState {
+    @NonNull final com.android.extensions.xr.environment.PassthroughVisibilityState mState;
+
+    PassthroughVisibilityStateImpl(
+            @NonNull com.android.extensions.xr.environment.PassthroughVisibilityState state) {
+        mState = state;
+    }
+
+    @Override
+    public @PassthroughVisibilityState.State int getCurrentState() {
+        return mState.getCurrentState();
+    }
+
+    @Override
+    public float getOpacity() {
+        return mState.getOpacity();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || !(other instanceof PassthroughVisibilityStateImpl)) {
+            return false;
+        }
+        PassthroughVisibilityStateImpl impl = (PassthroughVisibilityStateImpl) other;
+        return mState.equals(impl.mState);
+    }
+
+    @Override
+    public int hashCode() {
+        return mState.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return mState.toString();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/function/Consumer.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/function/Consumer.java
new file mode 100644
index 0000000..2507c9e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/function/Consumer.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Represents a function that accepts an argument and produces no result. It is used internally to
+ * avoid using Java 8 functional interface that leads to desugaring and Proguard shrinking.
+ *
+ * @param <T> the type of the input of the function
+ * @see java.util.function.Consumer
+ */
+@FunctionalInterface
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Consumer<T> {
+    /**
+     * Performs the operation on the given argument
+     *
+     * @param t the input argument
+     */
+    void accept(T t);
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensions.java
new file mode 100644
index 0000000..e2062f4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensions.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import android.media.AudioManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Provides spatial audio extensions on the framework {@link AudioManagerExtensions} class. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface AudioManagerExtensions {
+
+    /**
+     * Play a spatialized sound effect for sound sources that will be rendered in 3D space as a
+     * point source.
+     *
+     * @param audioManager The {@link AudioManager} to use to play the sound effect.
+     * @param effectType The type of sound effect.
+     * @param attributes attributes to specify sound source in 3D. {@link PointSourceAttributes}.
+     */
+    default void playSoundEffectAsPointSource(
+            @NonNull AudioManager audioManager,
+            int effectType,
+            @NonNull PointSourceAttributes attributes) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensionsImpl.java
new file mode 100644
index 0000000..6e2f5b1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioManagerExtensionsImpl.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import android.media.AudioManager;
+
+import androidx.annotation.NonNull;
+
+/** Wraps a {@link com.android.extensions.xr.media.AudioManagerExtensions}. */
+class AudioManagerExtensionsImpl implements AudioManagerExtensions {
+    @NonNull final com.android.extensions.xr.media.AudioManagerExtensions mAudioManager;
+
+    AudioManagerExtensionsImpl(
+            @NonNull com.android.extensions.xr.media.AudioManagerExtensions audioManager) {
+        requireNonNull(audioManager);
+        mAudioManager = audioManager;
+    }
+
+    @Override
+    public void playSoundEffectAsPointSource(
+            @NonNull AudioManager audioManager,
+            int effectType,
+            @NonNull PointSourceAttributes attributes) {
+        mAudioManager.playSoundEffectAsPointSource(
+                audioManager,
+                effectType,
+                PointSourceAttributesHelper.convertToFramework(attributes));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensions.java
new file mode 100644
index 0000000..039d7da
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensions.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import android.media.AudioTrack;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+/** Provides spatial audio extensions on the framework {@link AudioTrack} class. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface AudioTrackExtensions {
+    /**
+     * Gets the {@link PointSourceAttributes} of the provided {@link AudioTrack}.
+     *
+     * @param track The {@link AudioTrack} from which to get the {@link PointSourceAttributes}.
+     * @return The {@link PointSourceAttributes} of the provided track, null if not set.
+     */
+    @Nullable
+    default PointSourceAttributes getPointSourceAttributes(@NonNull AudioTrack track) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the {@link SoundFieldAttributes} of the provided {@link AudioTrack}.
+     *
+     * @param track The {@link AudioTrack} from which to get the {@link SoundFieldAttributes}.
+     * @return The {@link SoundFieldAttributes} of the provided track, null if not set.
+     */
+    @Nullable
+    default SoundFieldAttributes getSoundFieldAttributes(@NonNull AudioTrack track) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the {@link SourceType} of the provided {@link AudioTrack}. This value is implicitly set
+     * depending one which type of attributes was used to configure the builder. Will return {@link
+     * SOURCE_TYPE_BYPASS} for tracks that didn't use spatial audio attributes.
+     *
+     * @param track The {@link AudioTrack} from which to get the {@link SourceType}.
+     * @return The {@link SourceType} of the provided track.
+     */
+    default @SpatializerExtensions.SourceType int getSpatialSourceType(@NonNull AudioTrack track) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the {@link PointSourceAttributes} on the provided {@link AudioTrack.Builder}.
+     *
+     * @param builder The Builder on which to set the attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {AudioTrack.Builder} instance provided.
+     */
+    @NonNull
+    default AudioTrack.Builder setPointSourceAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull PointSourceAttributes attributes) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the {@link SoundFieldAttributes} on the provided {@link AudioTrack.Builder}.
+     *
+     * @param builder The Builder on which to set the attributes.
+     * @param attributes The sound field attributes to be set.
+     * @return The same {AudioTrack.Builder} instance provided.
+     */
+    @NonNull
+    default AudioTrack.Builder setSoundFieldAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull SoundFieldAttributes attributes) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensionsImpl.java
new file mode 100644
index 0000000..f3cb5705
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/AudioTrackExtensionsImpl.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import android.media.AudioTrack;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** Wraps a {@link com.android.extensions.xr.media.AudioTrackExtensions}. */
+class AudioTrackExtensionsImpl implements AudioTrackExtensions {
+    @NonNull final com.android.extensions.xr.media.AudioTrackExtensions mAudioTrack;
+
+    AudioTrackExtensionsImpl(
+            @NonNull com.android.extensions.xr.media.AudioTrackExtensions audioTrack) {
+        requireNonNull(audioTrack);
+        mAudioTrack = audioTrack;
+    }
+
+    @Override
+    @Nullable
+    public PointSourceAttributes getPointSourceAttributes(@NonNull AudioTrack track) {
+        return PointSourceAttributesHelper.convertToExtensions(
+                mAudioTrack.getPointSourceAttributes(track));
+    }
+
+    @Override
+    @Nullable
+    public SoundFieldAttributes getSoundFieldAttributes(@NonNull AudioTrack track) {
+        return SoundFieldAttributesHelper.convertToExtensions(
+                mAudioTrack.getSoundFieldAttributes(track));
+    }
+
+    @Override
+    public @SpatializerExtensions.SourceType int getSpatialSourceType(@NonNull AudioTrack track) {
+        return mAudioTrack.getSpatialSourceType(track);
+    }
+
+    @Override
+    @NonNull
+    public AudioTrack.Builder setPointSourceAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull PointSourceAttributes attributes) {
+        return mAudioTrack.setPointSourceAttributes(
+                builder, PointSourceAttributesHelper.convertToFramework(attributes));
+    }
+
+    @Override
+    @NonNull
+    public AudioTrack.Builder setSoundFieldAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull SoundFieldAttributes attributes) {
+        return mAudioTrack.setSoundFieldAttributes(
+                builder, SoundFieldAttributesHelper.convertToFramework(attributes));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensions.java
new file mode 100644
index 0000000..dd6d97a5
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensions.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import android.media.MediaPlayer;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Provides spatial audio extensions on the framework {@link MediaPlayer} class. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface MediaPlayerExtensions {
+
+    /**
+     * @param mediaPlayer The {@link MediaPlayer} on which to set the attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {@link MediaPlayer} instance provided.
+     */
+    @NonNull
+    default MediaPlayer setPointSourceAttributes(
+            @NonNull MediaPlayer mediaPlayer, @NonNull PointSourceAttributes attributes) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the {@link SoundFieldAttributes} on the provided {@link MediaPlayer}.
+     *
+     * @param mediaPlayer The {@link MediaPlayer} on which to set the attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {@link MediaPlayer} instance provided.
+     */
+    @NonNull
+    default MediaPlayer setSoundFieldAttributes(
+            @NonNull MediaPlayer mediaPlayer, @NonNull SoundFieldAttributes attributes) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensionsImpl.java
new file mode 100644
index 0000000..d8e0478
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaPlayerExtensionsImpl.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import android.media.MediaPlayer;
+
+import androidx.annotation.NonNull;
+
+/** Wraps a {@link com.android.extensions.xr.media.MediaPlayerExtensions}. */
+class MediaPlayerExtensionsImpl implements MediaPlayerExtensions {
+    @NonNull final com.android.extensions.xr.media.MediaPlayerExtensions mMediaPlayer;
+
+    MediaPlayerExtensionsImpl(
+            @NonNull com.android.extensions.xr.media.MediaPlayerExtensions mediaPlayer) {
+        requireNonNull(mediaPlayer);
+        mMediaPlayer = mediaPlayer;
+    }
+
+    @Override
+    @NonNull
+    public MediaPlayer setPointSourceAttributes(
+            MediaPlayer mediaPlayer, PointSourceAttributes attributes) {
+        return mMediaPlayer.setPointSourceAttributes(
+                mediaPlayer, PointSourceAttributesHelper.convertToFramework(attributes));
+    }
+
+    @Override
+    @NonNull
+    public MediaPlayer setSoundFieldAttributes(
+            MediaPlayer mediaPlayer, SoundFieldAttributes attributes) {
+        return mMediaPlayer.setSoundFieldAttributes(
+                mediaPlayer, SoundFieldAttributesHelper.convertToFramework(attributes));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaTypeConverter.java
new file mode 100644
index 0000000..ebaddd2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/MediaTypeConverter.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class MediaTypeConverter {
+
+    private MediaTypeConverter() {}
+
+    /**
+     * Converts a {@link com.android.extensions.xr.media.XrSpatialAudioExtensions} to a library
+     * type.
+     *
+     * @param extensions The {@link com.android.extensions.xr.media.XrSpatialAudioExtensions} to
+     *     convert.
+     * @return The library type of the {@link XrSpatialAudioExtensions}.
+     */
+    @NonNull
+    public static XrSpatialAudioExtensions toLibrary(
+            @NonNull com.android.extensions.xr.media.XrSpatialAudioExtensions extensions) {
+        requireNonNull(extensions);
+
+        return new XrSpatialAudioExtensionsImpl(extensions);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributes.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributes.java
new file mode 100644
index 0000000..09579cd
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributes.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.XrExtensionsProvider;
+import androidx.xr.extensions.node.Node;
+
+/** {@link PointSourceAttributes} is used to configure a sound be spatialized as a 3D point. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PointSourceAttributes {
+    private Node mNode;
+
+    private PointSourceAttributes(@NonNull Node node) {
+        mNode = node;
+    }
+
+    /**
+     * The {@link Node} to which this sound source is attached. The sound source will use the 3D
+     * transform of the Node. The node returned from this method must be parented to a node in the
+     * scene.
+     *
+     * @return The {@link Node} to which the sound source is attached.
+     */
+    public @NonNull Node getNode() {
+        return mNode;
+    }
+
+    /** Builder class for {@link PointSourceAttributes} */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static final class Builder {
+        private Node mNode;
+
+        public Builder() {}
+
+        /**
+         * @param node The {@link Node} to use to position the sound source.
+         * @return The Builder instance.
+         */
+        public @NonNull Builder setNode(@NonNull Node node) {
+            mNode = node;
+            return this;
+        }
+
+        /**
+         * Creates a new {@link PointSourceAttributes} to be used. If no {@link Node} is provided,
+         * this will create a new {@link Node} that must be parented to a node in the current scene.
+         *
+         * @return A new {@link PointSourceAttributes} object.
+         */
+        @NonNull
+        public PointSourceAttributes build() throws UnsupportedOperationException {
+            if (mNode == null) {
+                mNode = XrExtensionsProvider.getXrExtensions().createNode();
+            }
+
+            return new PointSourceAttributes(mNode);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributesHelper.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributesHelper.java
new file mode 100644
index 0000000..709ce10
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/PointSourceAttributesHelper.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import androidx.xr.extensions.node.NodeTypeConverter;
+
+// TODO(bvanderlaan): Replace this with a MediaTypeConverter for consistency with other modules
+class PointSourceAttributesHelper {
+
+    private PointSourceAttributesHelper() {}
+
+    static com.android.extensions.xr.media.PointSourceAttributes convertToFramework(
+            PointSourceAttributes attributes) {
+
+        return new com.android.extensions.xr.media.PointSourceAttributes.Builder()
+                .setNode(NodeTypeConverter.toFramework(attributes.getNode()))
+                .build();
+    }
+
+    static PointSourceAttributes convertToExtensions(
+            com.android.extensions.xr.media.PointSourceAttributes fwkAttributes) {
+
+        return new PointSourceAttributes.Builder()
+                .setNode(NodeTypeConverter.toLibrary(fwkAttributes.getNode()))
+                .build();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributes.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributes.java
new file mode 100644
index 0000000..dff8cd57
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributes.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** {@link SoundFieldAttributes} is used to configure ambisonic sound sources. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SoundFieldAttributes {
+    private int mAmbisonicsOrder;
+
+    private SoundFieldAttributes(int ambisonicsOrder) {
+        mAmbisonicsOrder = ambisonicsOrder;
+    }
+
+    /**
+     * @return The {@link SpatializerExtensions.AmbisonicsOrder} of this sound source.
+     */
+    @SpatializerExtensions.AmbisonicsOrder
+    public int getAmbisonicsOrder() {
+        return mAmbisonicsOrder;
+    }
+
+    /** Builder class for {@link SoundFieldAttributes} */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static final class Builder {
+        private int mAmbisonicsOrder = SpatializerExtensions.AMBISONICS_ORDER_FIRST_ORDER;
+
+        public Builder() {}
+
+        /**
+         * @param ambisonicsOrder Sets the {@link SpatializerExtensions.AmbisonicsOrder} of this
+         *     sound source.
+         * @return The Builder instance.
+         */
+        public @NonNull Builder setAmbisonicsOrder(
+                @SpatializerExtensions.AmbisonicsOrder int ambisonicsOrder) {
+            mAmbisonicsOrder = ambisonicsOrder;
+            return this;
+        }
+
+        /**
+         * Creates a new {@link PointSourceAttributes} to be used. If no {@link Node} is provided,
+         * this will create a new {@link Node} that must be parented to a node in the current scene.
+         *
+         * @return A new {@link PointSourceAttributes} object.
+         */
+        @NonNull
+        public SoundFieldAttributes build() throws UnsupportedOperationException {
+            return new SoundFieldAttributes(mAmbisonicsOrder);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributesHelper.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributesHelper.java
new file mode 100644
index 0000000..ff9cb0b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundFieldAttributesHelper.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+// TODO(bvanderlaan): Replace this with a MediaTypeConverter for consistency with other modules
+class SoundFieldAttributesHelper {
+
+    private SoundFieldAttributesHelper() {}
+
+    static com.android.extensions.xr.media.SoundFieldAttributes convertToFramework(
+            SoundFieldAttributes attributes) {
+
+        com.android.extensions.xr.media.SoundFieldAttributes.Builder builder =
+                new com.android.extensions.xr.media.SoundFieldAttributes.Builder();
+        builder.setAmbisonicsOrder(attributes.getAmbisonicsOrder());
+        return builder.build();
+    }
+
+    static SoundFieldAttributes convertToExtensions(
+            com.android.extensions.xr.media.SoundFieldAttributes fwkAttributes) {
+
+        SoundFieldAttributes.Builder builder = new SoundFieldAttributes.Builder();
+        builder.setAmbisonicsOrder(fwkAttributes.getAmbisonicsOrder());
+        return builder.build();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensions.java
new file mode 100644
index 0000000..90d60f8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensions.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import android.media.SoundPool;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Provides spatial audio extensions on the framework {@link SoundPool} class. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SoundPoolExtensions {
+    /**
+     * Plays a spatialized sound effect emitted relative {@link Node} in the {@link
+     * PointSourceAttributes}.
+     *
+     * @param soundPool The {@link SoundPool} to use to the play the sound.
+     * @param soundID a soundId returned by the load() function.
+     * @param attributes attributes to specify sound source. {@link PointSourceAttributes}
+     * @param volume volume value (range = 0.0 to 1.0)
+     * @param priority stream priority (0 = lowest priority)
+     * @param loop loop mode (0 = no loop, -1 = loop forever)
+     * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+     * @return non-zero streamID if successful, zero if failed
+     */
+    default int playAsPointSource(
+            @NonNull SoundPool soundPool,
+            int soundID,
+            @NonNull PointSourceAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Plays a spatialized sound effect as a sound field.
+     *
+     * @param soundPool The {@link SoundPool} to use to the play the sound.
+     * @param soundID a soundId returned by the load() function.
+     * @param attributes attributes to specify sound source. {@link SoundFieldAttributes}
+     * @param volume volume value (range = 0.0 to 1.0)
+     * @param priority stream priority (0 = lowest priority)
+     * @param loop loop mode (0 = no loop, -1 = loop forever)
+     * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+     * @return non-zero streamID if successful, zero if failed
+     */
+    default int playAsSoundField(
+            @NonNull SoundPool soundPool,
+            int soundID,
+            @NonNull SoundFieldAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * @param soundPool The {@link SoundPool} to use to get its SourceType.
+     * @param streamID a streamID returned by the play(), playAsPointSource(), or
+     *     playAsSoundField().
+     * @return The {@link SpatializerExtensions.SourceType} for the given streamID.
+     */
+    default @SpatializerExtensions.SourceType int getSpatialSourceType(
+            @NonNull SoundPool soundPool, int streamID) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensionsImpl.java
new file mode 100644
index 0000000..2554ca4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SoundPoolExtensionsImpl.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import android.media.SoundPool;
+
+import androidx.annotation.NonNull;
+
+/** Wraps a {@link com.android.extensions.xr.media.SoundPoolExtensions}. */
+class SoundPoolExtensionsImpl implements SoundPoolExtensions {
+    @NonNull final com.android.extensions.xr.media.SoundPoolExtensions mSoundPool;
+
+    SoundPoolExtensionsImpl(
+            @NonNull com.android.extensions.xr.media.SoundPoolExtensions soundPool) {
+        requireNonNull(soundPool);
+        mSoundPool = soundPool;
+    }
+
+    @Override
+    public int playAsPointSource(
+            @NonNull SoundPool soundPool,
+            int soundID,
+            @NonNull PointSourceAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        com.android.extensions.xr.media.PointSourceAttributes internal =
+                PointSourceAttributesHelper.convertToFramework(attributes);
+
+        return mSoundPool.playAsPointSource(
+                soundPool, soundID, internal, volume, priority, loop, rate);
+    }
+
+    @Override
+    public int playAsSoundField(
+            @NonNull SoundPool soundPool,
+            int soundID,
+            @NonNull SoundFieldAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        com.android.extensions.xr.media.SoundFieldAttributes internal =
+                SoundFieldAttributesHelper.convertToFramework(attributes);
+
+        return mSoundPool.playAsSoundField(
+                soundPool, soundID, internal, volume, priority, loop, rate);
+    }
+
+    @Override
+    @SpatializerExtensions.SourceType
+    public int getSpatialSourceType(@NonNull SoundPool soundPool, int streamID) {
+        return mSoundPool.getSpatialSourceType(soundPool, streamID);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SpatializerExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SpatializerExtensions.java
new file mode 100644
index 0000000..dbd1880
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/SpatializerExtensions.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Extensions of the existing {@link Spatializer} class. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatializerExtensions {
+    /** Used to set the Ambisonics order of a {@link SoundFieldAttributes} */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                AMBISONICS_ORDER_FIRST_ORDER,
+                AMBISONICS_ORDER_SECOND_ORDER,
+                AMBISONICS_ORDER_THIRD_ORDER,
+            })
+    @Retention(SOURCE)
+    @interface AmbisonicsOrder {}
+
+    /** Specifies spatial rendering using First Order Ambisonics */
+    int AMBISONICS_ORDER_FIRST_ORDER = 0;
+
+    /** Specifies spatial rendering using Second Order Ambisonics */
+    int AMBISONICS_ORDER_SECOND_ORDER = 1;
+
+    /** Specifies spatial rendering using Third Order Ambisonics */
+    int AMBISONICS_ORDER_THIRD_ORDER = 2;
+
+    /** Represents the type of spatialization for an audio source */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                SOURCE_TYPE_BYPASS,
+                SOURCE_TYPE_POINT_SOURCE,
+                SOURCE_TYPE_SOUND_FIELD,
+            })
+    @Retention(SOURCE)
+    @interface SourceType {}
+
+    /** The sound source has not been spatialized with the Spatial Audio SDK. */
+    int SOURCE_TYPE_BYPASS = 0;
+
+    /** The sound source has been spatialized as a 3D point source. */
+    int SOURCE_TYPE_POINT_SOURCE = 1;
+
+    /** The sound source is an ambisonics sound field. */
+    int SOURCE_TYPE_SOUND_FIELD = 2;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensions.java
new file mode 100644
index 0000000..3c9f0e8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensions.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Provides new functionality of existing framework APIs needed to Spatialize audio sources. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface XrSpatialAudioExtensions {
+    /**
+     * @return {@link SoundPoolExtensions} instance to control spatial audio from a {@link
+     *     SoundPool}.
+     */
+    default @NonNull SoundPoolExtensions getSoundPoolExtensions() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * @return {@link AudioTrackExtensions} instance to control spatial audio from an {@link
+     *     AudioTrack}.
+     */
+    default @NonNull AudioTrackExtensions getAudioTrackExtensions() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * @return {@link AudioManagerExtensions} instance to control spatial audio from an {@link
+     *     AudioManager}.
+     */
+    default @NonNull AudioManagerExtensions getAudioManagerExtensions() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * @return {@link MediaPlayerExtensions} instance to control spatial audio from a {@link
+     *     MediaPlayer}.
+     */
+    default @NonNull MediaPlayerExtensions getMediaPlayerExtensions() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensionsImpl.java
new file mode 100644
index 0000000..9791bc0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/media/XrSpatialAudioExtensionsImpl.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.media;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+
+/** Provides new functionality of existing framework APIs needed to spatialize audio sources. */
+class XrSpatialAudioExtensionsImpl implements XrSpatialAudioExtensions {
+    @NonNull final com.android.extensions.xr.media.XrSpatialAudioExtensions mExtensions;
+
+    @NonNull private final SoundPoolExtensionsImpl mSoundPoolExtensions;
+    @NonNull private final AudioTrackExtensionsImpl mAudioTrackExtensions;
+    @NonNull private final AudioManagerExtensionsImpl mAudioManagerExtensions;
+    @NonNull private final MediaPlayerExtensionsImpl mMediaPlayerExtensions;
+
+    XrSpatialAudioExtensionsImpl(
+            @NonNull com.android.extensions.xr.media.XrSpatialAudioExtensions extensions) {
+        requireNonNull(extensions);
+        mExtensions = extensions;
+
+        mSoundPoolExtensions = new SoundPoolExtensionsImpl(mExtensions.getSoundPoolExtensions());
+        mAudioTrackExtensions = new AudioTrackExtensionsImpl(mExtensions.getAudioTrackExtensions());
+        mAudioManagerExtensions =
+                new AudioManagerExtensionsImpl(mExtensions.getAudioManagerExtensions());
+        mMediaPlayerExtensions =
+                new MediaPlayerExtensionsImpl(mExtensions.getMediaPlayerExtensions());
+    }
+
+    @Override
+    @NonNull
+    public SoundPoolExtensions getSoundPoolExtensions() {
+        return mSoundPoolExtensions;
+    }
+
+    @Override
+    @NonNull
+    public AudioTrackExtensions getAudioTrackExtensions() {
+        return mAudioTrackExtensions;
+    }
+
+    @Override
+    @NonNull
+    public AudioManagerExtensions getAudioManagerExtensions() {
+        return mAudioManagerExtensions;
+    }
+
+    @Override
+    @NonNull
+    public MediaPlayerExtensions getMediaPlayerExtensions() {
+        return mMediaPlayerExtensions;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEvent.java
new file mode 100644
index 0000000..64fa841
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEvent.java
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** A single 6DOF pointer event. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface InputEvent {
+    // clang-format off
+    /** The type of the source of this event. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                SOURCE_UNKNOWN,
+                SOURCE_HEAD,
+                SOURCE_CONTROLLER,
+                SOURCE_HANDS,
+                SOURCE_MOUSE,
+                SOURCE_GAZE_AND_GESTURE,
+            })
+    @Retention(SOURCE)
+    @interface Source {}
+
+    // clang-format on
+
+    // Unknown source.
+    int SOURCE_UNKNOWN = 0;
+
+    /**
+     * Event is based on the user's head. Ray origin is at average between eyes, pushed out to the
+     * near clipping plane for both eyes and points in direction head is facing. Action state is
+     * based on volume up button being depressed.
+     *
+     * <p>Events from this device type are considered sensitive and hover events are never sent.
+     */
+    int SOURCE_HEAD = 1;
+
+    /**
+     * Event is based on (one of) the user's controller(s). Ray origin and direction are for a
+     * controller aim pose as defined by OpenXR.
+     * (https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#semantic-paths-standard-pose-identifiers)
+     * Action state is based on the primary button on the controller, usually the bottom-most face
+     * button.
+     */
+    int SOURCE_CONTROLLER = 2;
+
+    /**
+     * Event is based on one of the user's hands. Ray is a hand aim pose, with origin between thumb
+     * and forefinger and points in direction based on hand orientation. Action state is based on a
+     * pinch gesture.
+     */
+    int SOURCE_HANDS = 3;
+
+    /**
+     * Event is based on a 2D mouse pointing device. Ray origin behaves the same as for
+     * DEVICE_TYPE_HEAD and points in direction based on mouse movement. During a drag, the ray
+     * origin moves approximating hand motion. The scrollwheel moves the ray away from / towards the
+     * user. Action state is based on the primary mouse button.
+     */
+    int SOURCE_MOUSE = 4;
+
+    /**
+     * Event is based on a mix of the head, eyes, and hands. Ray origin is at average between eyes
+     * and points in direction based on a mix of eye gaze direction and hand motion. During a
+     * two-handed zoom/rotate gesture, left/right pointer events will be issued; otherwise, default
+     * events are issued based on the gaze ray. Action state is based on if the user has done a
+     * pinch gesture or not.
+     *
+     * <p>Events from this device type are considered sensitive and hover events are never sent.
+     */
+    int SOURCE_GAZE_AND_GESTURE = 5;
+
+    /** Returns the source of this event. */
+    @Source
+    int getSource();
+
+    /** The type of the individual pointer. */
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                POINTER_TYPE_DEFAULT,
+                POINTER_TYPE_LEFT,
+                POINTER_TYPE_RIGHT,
+            })
+    @Retention(SOURCE)
+    @interface PointerType {}
+
+    // clang-format on
+
+    /**
+     * Default pointer type for the source (no handedness). Occurs for SOURCE_UNKNOWN, SOURCE_HEAD,
+     * SOURCE_MOUSE, and SOURCE_GAZE_AND_GESTURE.
+     */
+    int POINTER_TYPE_DEFAULT = 0;
+
+    /**
+     * Left hand / controller pointer.. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+     * SOURCE_GAZE_AND_GESTURE.
+     */
+    int POINTER_TYPE_LEFT = 1;
+
+    /**
+     * Right hand / controller pointer.. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+     * SOURCE_GAZE_AND_GESTURE.
+     */
+    int POINTER_TYPE_RIGHT = 2;
+
+    /** Returns the pointer type of this event. */
+    @PointerType
+    int getPointerType();
+
+    /** The time this event occurred, in the android.os.SystemClock#uptimeMillis time base. */
+    long getTimestamp();
+
+    /** The origin of the ray, in the receiver's task coordinate space. */
+    @NonNull
+    Vec3 getOrigin();
+
+    /**
+     * The direction the ray is pointing in, in the receiver's task coordinate space. Any point
+     * along the ray can be represented as origin + d * direction, where d is non-negative.
+     */
+    @NonNull
+    Vec3 getDirection();
+
+    /** Info about a single ray hit. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    interface HitInfo {
+        /**
+         * ID of the front-end Impress node within the subspace that was hit. Used by Split-Engine
+         * to create a handle to the node with the same entity ID. In case the node doesn't belong
+         * to a subspace the value will be 0, i.e.,
+         * utils::Entity::import(subspaceImpressNodeId).IsNull() == true.
+         *
+         * <p>ACTION_MOVE, ACTION_UP, and ACTION_CANCEL events will report the same node id as was
+         * hit during the initial ACTION_DOWN.
+         */
+        int getSubspaceImpressNodeId();
+
+        /**
+         * The CPM node that was hit.
+         *
+         * <p>ACTION_MOVE, ACTION_UP, and ACTION_CANCEL events will report the same node as was hit
+         * during the initial ACTION_DOWN.
+         */
+        @NonNull
+        Node getInputNode();
+
+        /**
+         * The ray hit position, in the receiver's task coordinate space.
+         *
+         * <p>All events may report the current ray's hit position. This can be null if there no
+         * longer is a collision between the ray and the input node (eg, during a drag event).
+         */
+        @Nullable
+        Vec3 getHitPosition();
+
+        /** The matrix transforming task node coordinates into the hit CPM node's coordinates. */
+        @NonNull
+        Mat4f getTransform();
+    }
+
+    /**
+     * Info about the first scene node (closest to the ray origin) that was hit by the input ray, if
+     * any. This will be null if no node was hit. Note that the hit node remains the same during an
+     * ongoing DOWN -> MOVE -> UP action, even if the pointer stops hitting the node during the
+     * action.
+     */
+    @Nullable
+    HitInfo getHitInfo();
+
+    /** Info about the second scene node from the same task that was hit, if any. */
+    @Nullable
+    HitInfo getSecondaryHitInfo();
+
+    /** Event dispatch flags. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(value = {DISPATCH_FLAG_NONE, DISPATCH_FLAG_CAPTURED_POINTER, DISPATCH_FLAG_2D})
+    @Retention(SOURCE)
+    @interface DispatchFlag {}
+
+    // Normal dispatch.
+    int DISPATCH_FLAG_NONE = 0;
+    // This event was dispatched to this receiver only because pointer capture was enabled.
+    int DISPATCH_FLAG_CAPTURED_POINTER = 1;
+    // This event was also dispatched as a 2D Android input event.
+    int DISPATCH_FLAG_2D = 2;
+
+    /** Returns the dispatch flags for this event. */
+    @DispatchFlag
+    int getDispatchFlags();
+
+    // clang-format off
+    /**
+     * Actions similar to Android's MotionEvent actions:
+     * https://developer.android.com/reference/android/view/MotionEvent for keeping track of a
+     * sequence of events on the same target, e.g., * HOVER_ENTER -> HOVER_MOVE -> HOVER_EXIT * DOWN
+     * -> MOVE -> UP
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                ACTION_DOWN,
+                ACTION_UP,
+                ACTION_MOVE,
+                ACTION_CANCEL,
+                ACTION_HOVER_MOVE,
+                ACTION_HOVER_ENTER,
+                ACTION_HOVER_EXIT,
+            })
+    @Retention(SOURCE)
+    @interface Action {}
+
+    // clang-format on
+
+    /** The primary action button or gesture was just pressed / started. */
+    int ACTION_DOWN = 0;
+
+    /**
+     * The primary action button or gesture was just released / stopped. The hit info represents the
+     * node that was originally hit (ie, as provided in the ACTION_DOWN event).
+     */
+    int ACTION_UP = 1;
+
+    /**
+     * The primary action button or gesture was pressed/active in the previous event, and is still
+     * pressed/active. The hit info represents the node that was originally hit (ie, as provided in
+     * the ACTION_DOWN event). The hit position may be null if the pointer is no longer hitting that
+     * node.
+     */
+    int ACTION_MOVE = 2;
+
+    /**
+     * While the primary action button or gesture was held, the pointer was disabled. This happens
+     * if you are using controllers and the battery runs out, or if you are using a source that
+     * transitions to a new pointer type, eg SOURCE_GAZE_AND_GESTURE.
+     */
+    int ACTION_CANCEL = 3;
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray continued to hit the
+     * same node. The hit info represents the node that was hit (may be null if pointer capture is
+     * enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    int ACTION_HOVER_MOVE = 4;
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray started to hit a new
+     * node. The hit info represents the node that is being hit (may be null if pointer capture is
+     * enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    int ACTION_HOVER_ENTER = 5;
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray stopped hitting the
+     * node that it was previously hitting. The hit info represents the node that was being hit (may
+     * be null if pointer capture is enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    int ACTION_HOVER_EXIT = 6;
+
+    /** Returns the current action associated with this input event. */
+    @Action
+    int getAction();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEventImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEventImpl.java
new file mode 100644
index 0000000..c6c109e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/InputEventImpl.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+class InputEventImpl implements InputEvent {
+    @NonNull private final com.android.extensions.xr.node.InputEvent mEvent;
+
+    InputEventImpl(@NonNull com.android.extensions.xr.node.InputEvent event) {
+        mEvent = event;
+    }
+
+    @Override
+    public int getSource() {
+        return mEvent.getSource();
+    }
+
+    @Override
+    public int getPointerType() {
+        return mEvent.getPointerType();
+    }
+
+    @Override
+    public long getTimestamp() {
+        return mEvent.getTimestamp();
+    }
+
+    @Override
+    @NonNull
+    public Vec3 getOrigin() {
+        com.android.extensions.xr.node.Vec3 origin = mEvent.getOrigin();
+        return new Vec3(origin.x, origin.y, origin.z);
+    }
+
+    @Override
+    @NonNull
+    public Vec3 getDirection() {
+        com.android.extensions.xr.node.Vec3 direction = mEvent.getDirection();
+        return new Vec3(direction.x, direction.y, direction.z);
+    }
+
+    @Override
+    @Nullable
+    public HitInfo getHitInfo() {
+        com.android.extensions.xr.node.InputEvent.HitInfo info = mEvent.getHitInfo();
+        return (info == null) ? null : new HitInfo(info);
+    }
+
+    @Override
+    @Nullable
+    public HitInfo getSecondaryHitInfo() {
+        com.android.extensions.xr.node.InputEvent.HitInfo info = mEvent.getSecondaryHitInfo();
+        return (info == null) ? null : new HitInfo(info);
+    }
+
+    @Override
+    public int getDispatchFlags() {
+        return mEvent.getDispatchFlags();
+    }
+
+    @Override
+    public int getAction() {
+        return mEvent.getAction();
+    }
+
+    static class HitInfo implements InputEvent.HitInfo {
+        @NonNull private final com.android.extensions.xr.node.InputEvent.HitInfo info;
+
+        HitInfo(@NonNull com.android.extensions.xr.node.InputEvent.HitInfo info) {
+            this.info = info;
+        }
+
+        @Override
+        public int getSubspaceImpressNodeId() {
+            return info.getSubspaceImpressNodeId();
+        }
+
+        @Override
+        @NonNull
+        public Node getInputNode() {
+            return NodeTypeConverter.toLibrary(info.getInputNode());
+        }
+
+        @Override
+        @Nullable
+        public Vec3 getHitPosition() {
+            com.android.extensions.xr.node.Vec3 position = info.getHitPosition();
+            return (position == null) ? null : new Vec3(position.x, position.y, position.z);
+        }
+
+        @Override
+        @NonNull
+        public Mat4f getTransform() {
+            float[] transform = info.getTransform().getFlattenedMatrix();
+            return new Mat4f(transform);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Mat4f.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Mat4f.java
new file mode 100644
index 0000000..fe551af
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Mat4f.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** 4x4 matrix. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Mat4f {
+    public Mat4f(@NonNull float[] m) {
+        for (int i = 0; i < 4; i++) {
+            for (int j = 0; j < 4; j++) {
+                this.m[i][j] = m[i * 4 + j];
+            }
+        }
+    }
+
+    /**
+     * The matrix data.
+     *
+     * <p>The matrix is stored in column-major order, i.e. the first row is the first column.
+     */
+    @NonNull public float[][] m = new float[4][4];
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Node.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Node.java
new file mode 100644
index 0000000..932fd85
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Node.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.os.Parcelable;
+import android.view.AttachedSurfaceControl;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.Consumer;
+
+import java.io.Closeable;
+import java.lang.annotation.Retention;
+import java.util.concurrent.Executor;
+
+/**
+ * Handle to a node in the SpaceFlinger scene graph that can also host a 2D Panel or 3D subspace.
+ *
+ * <p>A Node by itself does not have any visual representation. It merely defines a local space in
+ * its parent space. However, a node can also host a single 2D panel or 3D subspace. Once an element
+ * is hosted, the node must be attached to the rest of scene graph hierarchy for the element become
+ * visible and appear on-screen.
+ *
+ * <p>Note that {@link Node} uses a right-hand coordinate system, i.e. +X points to the right, +Y
+ * up, and +Z points towards the camera.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Node extends Parcelable {
+    /**
+     * Begins listening for 6DOF input events on this Node, and any descendant Nodes that do not
+     * have their own event listener set. The event listener is called on the provided Executor.
+     * Calling this method replaces any existing event listener for this node.
+     */
+    void listenForInput(@NonNull Consumer<InputEvent> listener, @NonNull Executor executor);
+
+    /** Removes the listener for 6DOF input events from this Node. */
+    void stopListeningForInput();
+
+    /**
+     * Sets the focus target for non-pointer input (eg, keyboard events) when this Node is clicked.
+     * The new target is the focusTarget's underlying View Root.
+     */
+    void setNonPointerFocusTarget(@NonNull AttachedSurfaceControl focusTarget);
+
+    /** Pointer capture states. */
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                POINTER_CAPTURE_STATE_PAUSED,
+                POINTER_CAPTURE_STATE_ACTIVE,
+                POINTER_CAPTURE_STATE_STOPPED,
+            })
+    @Retention(SOURCE)
+    public @interface PointerCaptureState {}
+
+    // clang-format on
+    int POINTER_CAPTURE_STATE_PAUSED = 0;
+    int POINTER_CAPTURE_STATE_ACTIVE = 1;
+    int POINTER_CAPTURE_STATE_STOPPED = 2;
+
+    /**
+     * Requests pointer capture. All XR input events that hit this node or any of its children are
+     * delivered as normal; any other input events that would otherwise be dispatched elsewhere will
+     * instead be delivered to the input queue of this node (without hit info).
+     *
+     * <p>The stateCallback is called immediately with the current state of this pointer capture.
+     * Whenever this node is visible and a descendant of a task that is not bounded (is in FSM or
+     * overlay space), pointer capture will be active; otherwise it will be paused.
+     *
+     * <p>If pointer capture is explicitly stopped by a new call to requestPointerCapture() on the
+     * same node, or by a call to stopPointerCapture(), POINTER_CAPTURE_STATE_STOPPED is passed (and
+     * the stateCallback will not be called subsequently; also, the app can be sure that no more
+     * captured pointer events will be delivered based on that request). This also occurs if the
+     * node is destroyed without explicitly stopping pointer capture, or if a new call to
+     * requestPointerCapture() is made on the same node without stopping the previous request.
+     *
+     * <p>If there are multiple pointer capture requests (eg from other apps) that could be active
+     * at the same time, the most recently requested one is activated; all other requests stay
+     * paused.
+     *
+     * <p>There can only be a single request per Node. If a new requestPointerCapture() call is made
+     * on the same node without stopping the previous pointer capture request, the previous request
+     * is automatically stopped.
+     *
+     * @param stateCallback a callback that will be called when pointer capture state changes.
+     * @param executor the executor the callback will be called on.
+     */
+    void requestPointerCapture(
+            @NonNull Consumer</* @PointerCaptureState */ Integer> stateCallback,
+            @NonNull Executor executor);
+
+    /**
+     * Disables previously-requested pointer capture on this node. The stateCallback callback will
+     * be called with POINTER_CAPTURE_STOPPED.
+     */
+    void stopPointerCapture();
+
+    /**
+     * Subscribes to the transform of this node, relative to the OpenXR reference space used as
+     * world space for the shared scene. See {@code XrExtensions.getOpenXrWorldSpaceType()}. The
+     * provided matrix transforms a point in this node's local coordinate system into a point in
+     * world space coordinates. For example, {@code NodeTransform.getTransform()} * (0, 0, 0, 1) is
+     * the position of this node in world space. The first non-null transform will be returned
+     * immediately after the subscription set-up is complete. Note that the returned closeable must
+     * be closed by calling {@code close()} to prevent wasting system resources associated with the
+     * subscription.
+     *
+     * @param transformCallback a callback that will be called when this node's transform changes.
+     * @param executor the executor the callback will be called on.
+     * @return a Closeable that must be used to cancel the subscription by calling {@code close()}.
+     */
+    @NonNull
+    Closeable subscribeToTransform(
+            @NonNull Consumer<NodeTransform> transformCallback, @NonNull Executor executor);
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeImpl.java
new file mode 100644
index 0000000..6829768
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeImpl.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.util.Objects.requireNonNull;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.view.AttachedSurfaceControl;
+
+import androidx.annotation.NonNull;
+import androidx.xr.extensions.Consumer;
+
+import java.io.Closeable;
+import java.util.concurrent.Executor;
+
+class NodeImpl implements Node {
+    @NonNull final com.android.extensions.xr.node.Node mNode;
+
+    NodeImpl(@NonNull com.android.extensions.xr.node.Node node) {
+        requireNonNull(node);
+        mNode = node;
+    }
+
+    @Override
+    public void listenForInput(@NonNull Consumer<InputEvent> listener, @NonNull Executor executor) {
+        mNode.listenForInput((event) -> listener.accept(new InputEventImpl(event)), executor);
+    }
+
+    @Override
+    public void stopListeningForInput() {
+        mNode.stopListeningForInput();
+    }
+
+    @Override
+    public void setNonPointerFocusTarget(@NonNull AttachedSurfaceControl focusTarget) {
+        mNode.setNonPointerFocusTarget(focusTarget);
+    }
+
+    @Override
+    public void requestPointerCapture(
+            @NonNull Consumer</* @PointerCaptureState */ Integer> stateCallback,
+            @NonNull Executor executor) {
+        mNode.requestPointerCapture((state) -> stateCallback.accept(state), executor);
+    }
+
+    @Override
+    public void stopPointerCapture() {
+        mNode.stopPointerCapture();
+    }
+
+    @Override
+    @NonNull
+    public Closeable subscribeToTransform(
+            @NonNull Consumer<NodeTransform> transformCallback, @NonNull Executor executor) {
+        return mNode.subscribeToTransform(
+                (transform) -> {
+                    transformCallback.accept(new NodeTransformImpl(transform));
+                },
+                executor);
+    }
+
+    @Override
+    public int describeContents() {
+        return mNode.describeContents();
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeParcelable(mNode, flags);
+    }
+
+    @Override
+    public String toString() {
+        return mNode.toString();
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (this == object) return true;
+        if (object == null) return false;
+
+        if (object instanceof NodeImpl) {
+            return this.mNode.equals(((NodeImpl) object).mNode);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return mNode.hashCode();
+    }
+
+    public static final Parcelable.Creator<NodeImpl> CREATOR =
+            new Parcelable.Creator<NodeImpl>() {
+                @Override
+                public NodeImpl createFromParcel(Parcel in) {
+                    return new NodeImpl(
+                            in.readParcelable(
+                                    com.android.extensions.xr.node.Node.class.getClassLoader()));
+                }
+
+                @Override
+                public NodeImpl[] newArray(int size) {
+                    return new NodeImpl[size];
+                }
+            };
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransaction.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransaction.java
new file mode 100644
index 0000000..f72074c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransaction.java
@@ -0,0 +1,492 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.os.IBinder;
+import android.view.SurfaceControl;
+import android.view.SurfaceControlViewHost.SurfacePackage;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.asset.EnvironmentToken;
+import androidx.xr.extensions.asset.GltfAnimation;
+import androidx.xr.extensions.asset.GltfModelToken;
+import androidx.xr.extensions.asset.SceneToken;
+import androidx.xr.extensions.passthrough.PassthroughState;
+import androidx.xr.extensions.subspace.Subspace;
+
+import java.io.Closeable;
+import java.lang.annotation.Retention;
+
+/**
+ * An atomic set of changes to apply to a set of {@link Node}s.
+ *
+ * <p>Note that {@link Node} uses a right-hand coordinate system, i.e. +X points to the right, +Y
+ * up, and +Z points towards the camera.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface NodeTransaction extends Closeable {
+    /**
+     * Sets a name for the node that is used to it in `adb dumpsys cpm` output log.
+     *
+     * <p>While the name does not have to be globally unique, it is recommended to set a unique name
+     * for each node for ease of debugging.
+     *
+     * @param node The node to be updated.
+     * @param name The debug name of the node.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setName(@NonNull Node node, @NonNull String name) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the parent of this node to the given node.
+     *
+     * <p>This method detaches the node from its current branch and moves into the new parent's
+     * hierarchy (if any). If parent parameter is `null`, the node will be orphaned and removed from
+     * the rendering tree until it is reattached to another node that is in the root hierarchy.
+     *
+     * @param node The node to be updated.
+     * @param parent The new parent of the node or `null` if the node is to be removed from the
+     *     rendering tree.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setParent(@NonNull Node node, @Nullable Node parent) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the position of the node in the local coordinate space (parent space).
+     *
+     * @param node The node to be updated.
+     * @param x The 'x' distance in meters from parent's origin.
+     * @param y The 'y' distance in meters from parent's origin.
+     * @param z The 'z' distance in meters from parent's origin.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setPosition(@NonNull Node node, float x, float y, float z) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Rotates the node by the quaternion specified by x, y, z, and w components in the local
+     * coordinate space.
+     *
+     * @param node The node to be updated.
+     * @param x The x component of the quaternion.
+     * @param y The y component of the quaternion.
+     * @param z The z component of the quaternion.
+     * @param w The w component of the quaternion.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setOrientation(
+            @NonNull Node node, float x, float y, float z, float w) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Scales the node along the x, y, and z axis in the local coordinate space.
+     *
+     * <p>For 2D panels, this method scales the panel in the world, increasing its visual size
+     * without changing the buffer size. It will not trigger a relayout and will not affect its
+     * enclosing view's layout configuration.
+     *
+     * @param node The node to be updated.
+     * @param sx The scaling factor along the x-axis.
+     * @param sy The scaling factor along the y-axis.
+     * @param sz The scaling factor along the z-axis.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setScale(@NonNull Node node, float sx, float sy, float sz) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the opacity of the node's content to a value between [0..1].
+     *
+     * @param node The node to be updated.
+     * @param value The new opacity amount in range of [0..1].
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setAlpha(@NonNull Node node, float value) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Changes the visibility of the node and its content.
+     *
+     * @param node The node to be updated.
+     * @param isVisible Whether the node is visible.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setVisibility(@NonNull Node node, boolean isVisible) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Configures the node to host and control the given surface data.
+     *
+     * <p>Passing a 'null' for surfaceControl parameter will disassociate it from the node, so the
+     * same node can be used to host another surface or volume data.
+     *
+     * @param node The node to be updated.
+     * @param surfaceControl Handle to an on-screen surface managed by the system compositor, or
+     *     'null' to disassociate the currently hosted surface from the node.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setSurfaceControl(
+            @Nullable Node node, @NonNull SurfaceControl surfaceControl) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Configures the node to host and control the given surface data.
+     *
+     * <p>This method is similar to {@link #setSurfaceControl(Node, SurfaceControl)} and is provided
+     * for convenience.
+     *
+     * @param node The node to be updated.
+     * @param surfacePackage The package that contains the {@link SurfaceControl}, or 'null' to
+     *     disassociate the currently hosted surface from the node.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setSurfacePackage(
+            @Nullable Node node, @NonNull SurfacePackage surfacePackage) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Crops the 2D buffer of the Surface hosted by this node to match the given bounds in pixels.
+     *
+     * <p>This method only applies to nodes that host a {@link SurfaceControl} set by {@link
+     * #setSurfaceControl}.
+     *
+     * @param surfaceControl The on-screen surface.
+     * @param widthPx The width of the surface in pixels.
+     * @param heightPx The height of the surface in pixels.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setWindowBounds(
+            @NonNull SurfaceControl surfaceControl, int widthPx, int heightPx) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Crops the 2D buffer of the Surface hosted by this node to match the given bounds in pixels.
+     *
+     * <p>This method is similar to {@link #setWindowBounds(SurfaceControl, int, int)} and is
+     * provided for convenience.
+     *
+     * @param surfacePackage The package that contains the {@link SurfaceControl}.
+     * @param widthPx The width of the surface in pixels.
+     * @param heightPx The height of the surface in pixels.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    @NonNull
+    default NodeTransaction setWindowBounds(
+            @NonNull SurfacePackage surfacePackage, int widthPx, int heightPx) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Curves the XY plane of the node around the y-axis and towards the positive z-axis.
+     *
+     * <p>This method essentially curves the x-axis of the node, moving and rotating its children to
+     * align with the new x-axis shape. It will also curve the children's x-axes in a similar
+     * manner.
+     *
+     * <p>If this node is hosting a 2D panel, setting a curvature will bend the panel along the Y
+     * axis, projecting it onto a cylinder defined by the given radius.
+     *
+     * <p>To remove the curvature, set the radius to 0.
+     *
+     * @param node The node to be updated.
+     * @param curvature A positive value equivalent to 1/radius, where 'radius' represents the
+     *     radial distance of the polar coordinate system that is used to curve the x-axis. Setting
+     *     this value to 0 will straighten the axis and remove its curvature.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    default @NonNull NodeTransaction setCurvature(@NonNull Node node, float curvature) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the resolution of 2D surfaces under this node.
+     *
+     * <p>The sizes of 2D surfaces under this node will be set according to their 2D pixel
+     * dimensions and the pixelsPerMeter value. The pixelsPerMeter value is propagated to child
+     * nodes.
+     *
+     * @param node The node to be updated.
+     * @param pixelsPerMeter The number of pixels per meter to use when sizing 2D surfaces.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setPixelResolution(@NonNull Node node, float pixelsPerMeter) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            flag = true,
+            value = {
+                X_POSITION_IN_PIXELS,
+                Y_POSITION_IN_PIXELS,
+                Z_POSITION_IN_PIXELS,
+                POSITION_FROM_PARENT_TOP_LEFT,
+            })
+    @Retention(SOURCE)
+    public @interface PixelPositionFlags {}
+
+    // clang-format on
+
+    int X_POSITION_IN_PIXELS = 0x01;
+    int Y_POSITION_IN_PIXELS = 0x02;
+    int Z_POSITION_IN_PIXELS = 0x04;
+    // POSITION_FROM_PARENT_TOP_LEFT makes it so the node's position is relative to the top left
+    // corner of the parent node, instead of the center. Only relevant if the parent node has a size
+    // (currently this is only true for surface tracking nodes).
+    int POSITION_FROM_PARENT_TOP_LEFT = 0x40;
+
+    /**
+     * Sets whether position is interpreted in meters or in pixels for each dimension.
+     *
+     * <p>The sizes of 2D surfaces under this node will be set according to their 2D pixel
+     * dimensions and the pixelsPerMeter value. The pixelsPerMeter value is propagated to child
+     * nodes.
+     *
+     * @param node The node to be updated.
+     * @param pixelPositionFlags Flags indicating which dimensins of the local position of the node
+     *     should be interpreted as pixel values (as opposed to the default meters).
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setPixelPositioning(
+            @NonNull Node node, @PixelPositionFlags int pixelPositionFlags) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Renders a previously loaded glTF model.
+     *
+     * <p>The token must belong to a previously loaded glTF model that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param gltfModelToken The token of a glTF model that was previously loaded.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    default @NonNull NodeTransaction setGltfModel(
+            @NonNull Node node, @NonNull GltfModelToken gltfModelToken) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Renders a previously loaded environment.
+     *
+     * <p>The token must belong to a previously loaded environment that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param environmentToken The token of an environment that was previously loaded.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    default @NonNull NodeTransaction setEnvironment(
+            @NonNull Node node, @NonNull EnvironmentToken environmentToken) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Renders a previously loaded Impress scene.
+     *
+     * <p>The token must belong to a previously loaded Impress scene that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param sceneToken The token of an Impress scene that was previously loaded.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    default @NonNull NodeTransaction setImpressScene(
+            @NonNull Node node, @NonNull SceneToken sceneToken) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Animates a previously loaded glTF model.
+     *
+     * @param node The node to be updated.
+     * @param gltfAnimationName The name of the glTF animation.
+     * @param gltfAnimationState The {@link GltfAnimation.State} state of the glTF animation.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    default @NonNull NodeTransaction setGltfAnimation(
+            @NonNull Node node,
+            @NonNull String gltfAnimationName,
+            @NonNull GltfAnimation.State gltfAnimationState) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the transform of the node on a per-frame basis from a previously created anchor.
+     *
+     * <p>The client who created the anchor and provided the ID will always remain the owner of the
+     * anchor.
+     *
+     * <p>Modifying the transform of the node will only be applied if or when the anchor is no
+     * longer linked to the node, or if the anchor is no longer locatable.
+     *
+     * <p>A node can be unlinked from an anchor by setting the ID to null. Note that this does not
+     * destroy the actual anchor.
+     *
+     * @param node The node to be updated.
+     * @param anchorId The ID of a previously created anchor.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setAnchorId(@NonNull Node node, @Nullable IBinder anchorId) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets a subspace to be used.
+     *
+     * @param node The node to be updated.
+     * @param subspace The previously created subspace to be associated with the node.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setSubspace(@NonNull Node node, @NonNull Subspace subspace) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Updates the passthrough state.
+     *
+     * @param node The node to be updated.
+     * @param passthroughOpacity The opacity of the passthrough layer where 0.0 means no passthrough
+     *     and 1.0 means full passthrough.
+     * @param passthroughMode The {@link PassthroughState.Mode} mode that the passthrough will use.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setPassthroughState(
+            @NonNull Node node,
+            float passthroughOpacity,
+            @PassthroughState.Mode int passthroughMode) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Enables reform UX for a node.
+     *
+     * @param node The node to be updated.
+     * @param options Configuration options for the reform UX.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction enableReform(
+            @NonNull Node node, @NonNull ReformOptions options) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Updates the size of the reform UX.
+     *
+     * @param node The node to be updated.
+     * @param reformSize The new size in meters that should be used to lay out the reform UX.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setReformSize(@NonNull Node node, @NonNull Vec3 reformSize) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Disables reform UX for a node.
+     *
+     * @param node The node to be updated.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction disableReform(@NonNull Node node) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Sets the corner radius for 2D surfaces under this node.
+     *
+     * <p>The corner radius is propagated to child nodes.
+     *
+     * @param node The node to be updated.
+     * @param cornerRadius The corner radius for 2D surfaces under this node, in meters.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction setCornerRadius(@NonNull Node node, float cornerRadius) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Removes the corner radius from this node.
+     *
+     * @param node The node to be updated.
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction removeCornerRadius(@NonNull Node node) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Merges the given transaction into this one so that they can be submitted together to the
+     * system. All of the changes in the other transaction are moved into this one; the other
+     * transaction is left in an empty state.
+     *
+     * @return The reference to this {@link NodeTransaction} object that is currently being updated.
+     */
+    default @NonNull NodeTransaction merge(@NonNull NodeTransaction other) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Submits the queued transactions to backend.
+     *
+     * <p>This method will clear the existing transaction state so the same transaction object can
+     * be used for the next set of updates.
+     */
+    default void apply() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Closes and releases the native transaction object without applying it.
+     *
+     * <p>Note that a closed transaction cannot be used again.
+     */
+    @Override
+    default void close() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransactionImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransactionImpl.java
new file mode 100644
index 0000000..6d72f74
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransactionImpl.java
@@ -0,0 +1,284 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.util.Objects.requireNonNull;
+
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+import android.view.SurfaceControl;
+import android.view.SurfaceControlViewHost.SurfacePackage;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.asset.EnvironmentToken;
+import androidx.xr.extensions.asset.GltfAnimation;
+import androidx.xr.extensions.asset.GltfModelToken;
+import androidx.xr.extensions.asset.SceneToken;
+import androidx.xr.extensions.asset.TokenConverter;
+import androidx.xr.extensions.passthrough.PassthroughState;
+import androidx.xr.extensions.subspace.Subspace;
+import androidx.xr.extensions.subspace.SubspaceTypeConverter;
+
+class NodeTransactionImpl implements NodeTransaction {
+    @NonNull final com.android.extensions.xr.node.NodeTransaction transaction;
+
+    NodeTransactionImpl(@NonNull com.android.extensions.xr.node.NodeTransaction transaction) {
+        requireNonNull(transaction);
+        this.transaction = transaction;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setName(@NonNull Node node, @NonNull String name) {
+        transaction.setName(toFramework(node), name);
+        return this;
+    }
+
+    @Nullable
+    private com.android.extensions.xr.node.Node toFramework(@Nullable Node node) {
+        return NodeTypeConverter.toFramework(node);
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setParent(@NonNull Node node, @Nullable Node parent) {
+        transaction.setParent(toFramework(node), toFramework(parent));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setPosition(@NonNull Node node, float x, float y, float z) {
+        transaction.setPosition(toFramework(node), x, y, z);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setOrientation(@NonNull Node node, float x, float y, float z, float w) {
+        transaction.setOrientation(toFramework(node), x, y, z, w);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setScale(@NonNull Node node, float sx, float sy, float sz) {
+        transaction.setScale(toFramework(node), sx, sy, sz);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setAlpha(@NonNull Node node, float value) {
+        transaction.setAlpha(toFramework(node), value);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setVisibility(@NonNull Node node, boolean isVisible) {
+        transaction.setVisibility(toFramework(node), isVisible);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setSurfaceControl(
+            @Nullable Node node, @NonNull SurfaceControl surfaceControl) {
+        transaction.setSurfaceControl(toFramework(node), surfaceControl);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setSurfacePackage(
+            @Nullable Node node, @NonNull SurfacePackage surfacePackage) {
+        // This method has been deprecated in the platform side.
+        if (Build.VERSION.SDK_INT >= 34) {
+            requireNonNull(surfacePackage);
+            return setSurfaceControl(node, surfacePackage.getSurfaceControl());
+        } else {
+            Log.e("NodeTransaction", "setSurfacePackage is not supported in SDK lower then 34");
+            return this;
+        }
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setWindowBounds(
+            @NonNull SurfaceControl surfaceControl, int widthPx, int heightPx) {
+        transaction.setWindowBounds(surfaceControl, widthPx, heightPx);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setWindowBounds(
+            @NonNull SurfacePackage surfacePackage, int widthPx, int heightPx) {
+        // This method has been deprecated in the platform side.
+        if (Build.VERSION.SDK_INT >= 34) {
+            requireNonNull(surfacePackage);
+            return setWindowBounds(surfacePackage.getSurfaceControl(), widthPx, heightPx);
+        } else {
+            Log.e("NodeTransaction", "setSurfacePackage is not supported in SDK lower then 34");
+            return this;
+        }
+    }
+
+    @Override
+    @NonNull
+    @Deprecated
+    public NodeTransaction setCurvature(@NonNull Node node, float radius) {
+        transaction.setCurvature(toFramework(node), radius);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setPixelResolution(@NonNull Node node, float pixelsPerMeter) {
+        transaction.setPixelResolution(toFramework(node), pixelsPerMeter);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setPixelPositioning(
+            @NonNull Node node, @PixelPositionFlags int pixelPositionFlags) {
+        transaction.setPixelPositioning(toFramework(node), pixelPositionFlags);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    @Deprecated
+    public NodeTransaction setGltfModel(
+            @NonNull Node node, @NonNull GltfModelToken gltfModelToken) {
+        transaction.setGltfModel(toFramework(node), TokenConverter.toFramework(gltfModelToken));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    @Deprecated
+    public NodeTransaction setEnvironment(
+            @NonNull Node node, @NonNull EnvironmentToken environmentToken) {
+        transaction.setEnvironment(toFramework(node), TokenConverter.toFramework(environmentToken));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    @Deprecated
+    public NodeTransaction setImpressScene(@NonNull Node node, @NonNull SceneToken sceneToken) {
+        transaction.setImpressScene(toFramework(node), TokenConverter.toFramework(sceneToken));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    @Deprecated
+    public NodeTransaction setGltfAnimation(
+            @NonNull Node node,
+            @NonNull String gltfAnimationName,
+            @NonNull GltfAnimation.State gltfAnimationState) {
+        transaction.setGltfAnimation(
+                toFramework(node),
+                gltfAnimationName,
+                TokenConverter.toFramework(gltfAnimationState));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setAnchorId(@NonNull Node node, @Nullable IBinder anchorId) {
+        transaction.setAnchorId(toFramework(node), anchorId);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setSubspace(@NonNull Node node, @NonNull Subspace subspace) {
+        transaction.setSubspace(toFramework(node), SubspaceTypeConverter.toFramework(subspace));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setPassthroughState(
+            @NonNull Node node,
+            float passthroughOpacity,
+            @PassthroughState.Mode int passthroughMode) {
+        transaction.setPassthroughState(toFramework(node), passthroughOpacity, passthroughMode);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction enableReform(@NonNull Node node, @NonNull ReformOptions options) {
+        transaction.enableReform(toFramework(node), NodeTypeConverter.toFramework(options));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setReformSize(@NonNull Node node, @NonNull Vec3 reformSize) {
+        transaction.setReformSize(
+                toFramework(node),
+                new com.android.extensions.xr.node.Vec3(reformSize.x, reformSize.y, reformSize.z));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction disableReform(@NonNull Node node) {
+        transaction.disableReform(toFramework(node));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction setCornerRadius(@NonNull Node node, float cornerRadius) {
+        transaction.setCornerRadius(toFramework(node), cornerRadius);
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction removeCornerRadius(@NonNull Node node) {
+        transaction.removeCornerRadius(toFramework(node));
+        return this;
+    }
+
+    @Override
+    @NonNull
+    public NodeTransaction merge(@NonNull NodeTransaction transaction) {
+        this.transaction.merge(((NodeTransactionImpl) transaction).transaction);
+        return this;
+    }
+
+    @Override
+    public void apply() {
+        transaction.apply();
+    }
+
+    @Override
+    public void close() {
+        transaction.close();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransform.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransform.java
new file mode 100644
index 0000000..22fb3fa
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransform.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** interface containing the Node transform */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface NodeTransform {
+    /**
+     * Get the transformation matrix associated with the node.
+     *
+     * <p>The provided matrix transforms a point in this node's local coordinate system into a point
+     * in world space coordinates. For example, {@code NodeTransform.getTransform()} * (0, 0, 0, 1)
+     * is the position of this node in world space. The first non-null transform will be returned
+     * immediately after the subscription set-up is complete.
+     *
+     * @return A transformation matrix {@link Mat4f} containing the current transformation matrix of
+     *     this node.
+     */
+    @NonNull
+    Mat4f getTransform();
+
+    /**
+     * Get the timestamp at which the transformation matrix was recorded.
+     *
+     * <p>The time the record happened, in the android.os.SystemClock#uptimeNanos time base.
+     *
+     * @return A timestamp at which the transformation matrix was recorded.
+     */
+    long getTimestamp();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransformImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransformImpl.java
new file mode 100644
index 0000000..37089a2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTransformImpl.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.NonNull;
+
+class NodeTransformImpl implements NodeTransform {
+    @NonNull private final com.android.extensions.xr.node.NodeTransform mTransform;
+
+    NodeTransformImpl(@NonNull com.android.extensions.xr.node.NodeTransform transform) {
+        mTransform = transform;
+    }
+
+    @Override
+    @NonNull
+    public Mat4f getTransform() {
+        return new Mat4f(mTransform.getTransform().getFlattenedMatrix());
+    }
+
+    @Override
+    public long getTimestamp() {
+        return mTransform.getTimestamp();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTypeConverter.java
new file mode 100644
index 0000000..952340e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/NodeTypeConverter.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class NodeTypeConverter {
+
+    private NodeTypeConverter() {}
+
+    /**
+     * Converts a {@link Node} to a framework type.
+     *
+     * @param node The {@link Node} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.node.Node}.
+     */
+    @Nullable
+    public static com.android.extensions.xr.node.Node toFramework(@Nullable Node node) {
+        if (node == null) {
+            return null;
+        }
+
+        return ((NodeImpl) node).mNode;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.node.Node} to a library type.
+     *
+     * @param node The {@link com.android.extensions.xr.node.Node} to convert.
+     * @return The library type of the {@link Node}.
+     */
+    @Nullable
+    public static Node toLibrary(@Nullable com.android.extensions.xr.node.Node node) {
+        if (node == null) {
+            return null;
+        }
+
+        return new NodeImpl(node);
+    }
+
+    /**
+     * Converts a {@link Vec3} to a framework type.
+     *
+     * @param vec The {@link Vec3} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.node.Vec3}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.node.Vec3 toFramework(@NonNull Vec3 vec) {
+        requireNonNull(vec);
+        return new com.android.extensions.xr.node.Vec3(vec.x, vec.y, vec.z);
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.node.Vec3} to a library type.
+     *
+     * @param vec The {@link com.android.extensions.xr.node.Vec3} to convert.
+     * @return The library type of the {@link Vec3}.
+     */
+    @NonNull
+    public static Vec3 toLibrary(@NonNull com.android.extensions.xr.node.Vec3 vec) {
+        requireNonNull(vec);
+        return new Vec3(vec.x, vec.y, vec.z);
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.node.NodeTransaction} to a library type.
+     *
+     * @param transform The {@link com.android.extensions.xr.node.NodeTransaction} to convert.
+     * @return The framework type of the {@link NodeTransaction}.
+     */
+    @NonNull
+    public static NodeTransaction toLibrary(
+            @NonNull com.android.extensions.xr.node.NodeTransaction transform) {
+        requireNonNull(transform);
+        return new NodeTransactionImpl(transform);
+    }
+
+    /**
+     * Converts a {@link Quatf} to a framework type.
+     *
+     * @param value The {@link Quatf} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.node.Quatf}.
+     */
+    @NonNull
+    public static Quatf toLibrary(@NonNull com.android.extensions.xr.node.Quatf value) {
+        requireNonNull(value);
+        return new Quatf(value.x, value.y, value.z, value.w);
+    }
+
+    /**
+     * Converts a {@link ReformOptions} to a framework type.
+     *
+     * @param options The {@link ReformOptions} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.node.ReformOptions}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.node.ReformOptions toFramework(
+            @NonNull ReformOptions options) {
+        requireNonNull(options);
+        return ((ReformOptionsImpl) options).mOptions;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.node.ReformOptions} to a library type.
+     *
+     * @param options The {@link com.android.extensions.xr.node.ReformOptions} to convert.
+     * @return The library type of the {@link ReformOptions}.
+     */
+    @NonNull
+    public static ReformOptions toLibrary(
+            @NonNull com.android.extensions.xr.node.ReformOptions options) {
+        requireNonNull(options);
+        return new ReformOptionsImpl(options);
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.node.ReformEvent} to a library type.
+     *
+     * @param event The {@linkcom.android.extensions.xr.node.ReformEvent} to convert.
+     * @return The library type of the {@link ReformEvent}.
+     */
+    @NonNull
+    public static ReformEvent toLibrary(@NonNull com.android.extensions.xr.node.ReformEvent event) {
+        requireNonNull(event);
+        return new ReformOptionsImpl.ReformEventImpl(event);
+    }
+
+    /**
+     * Converts a {@link ReformEvent} to a framework type.
+     *
+     * @param event The {@link ReformEvent} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.node.ReformEvent}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.node.ReformEvent toFramework(
+            @NonNull ReformEvent event) {
+        requireNonNull(event);
+        return ((ReformOptionsImpl.ReformEventImpl) event).mEvent;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Quatf.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Quatf.java
new file mode 100644
index 0000000..d215ada
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Quatf.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.RestrictTo;
+
+/** Quaternion. q = w + xi + yj + zk */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Quatf {
+    public Quatf(float x, float y, float z, float w) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+        this.w = w;
+    }
+
+    public float x;
+    public float y;
+    public float z;
+    public float w;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformEvent.java
new file mode 100644
index 0000000..9491952
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformEvent.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** A reform (move / resize) event. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ReformEvent {
+    // clang-format off
+    /** The type of reform action this event is referring to. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                REFORM_TYPE_UNKNOWN,
+                REFORM_TYPE_MOVE,
+                REFORM_TYPE_RESIZE,
+            })
+    @Retention(SOURCE)
+    @interface ReformType {}
+
+    // clang-format on
+
+    int REFORM_TYPE_UNKNOWN = 0;
+    int REFORM_TYPE_MOVE = 1;
+    int REFORM_TYPE_RESIZE = 2;
+
+    /** Returns the type of reform action this event is referring to. */
+    @ReformType
+    int getType();
+
+    // clang-format off
+    /** The state of the reform action. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                REFORM_STATE_UNKNOWN,
+                REFORM_STATE_START,
+                REFORM_STATE_ONGOING,
+                REFORM_STATE_END,
+            })
+    @Retention(SOURCE)
+    @interface ReformState {}
+
+    // clang-format on
+
+    int REFORM_STATE_UNKNOWN = 0;
+    int REFORM_STATE_START = 1;
+    int REFORM_STATE_ONGOING = 2;
+    int REFORM_STATE_END = 3;
+
+    /** Returns the state of the reform action. */
+    @ReformState
+    int getState();
+
+    /** An identifier for this reform action. */
+    int getId();
+
+    /** The initial ray origin and direction, in task space. */
+    @NonNull
+    Vec3 getInitialRayOrigin();
+
+    /** The initial ray direction, in task space. */
+    @NonNull
+    Vec3 getInitialRayDirection();
+
+    /** The current ray origin and direction, in task space. */
+    @NonNull
+    Vec3 getCurrentRayOrigin();
+
+    /** The current ray direction, in task space. */
+    @NonNull
+    Vec3 getCurrentRayDirection();
+
+    /**
+     * For a move event, the proposed pose of the node, in task space (or relative to the parent
+     * node, if FLAG_POSE_RELATIVE_TO_PARENT was specified in the ReformOptions).
+     */
+    @NonNull
+    Vec3 getProposedPosition();
+
+    /**
+     * For a move event, the proposed orientation of the node, in task space (or relative to the
+     * parent node, if FLAG_POSE_RELATIVE_TO_PARENT was specified in the ReformOptions).
+     */
+    @NonNull
+    Quatf getProposedOrientation();
+
+    /** Scale will change with distance if ReformOptions.FLAG_SCALE_WITH_DISTANCE is set. */
+    @NonNull
+    Vec3 getProposedScale();
+
+    /**
+     * For a resize event, the proposed new size in meters. Note that in the initial implementation,
+     * the Z size may not be modified.
+     */
+    @NonNull
+    Vec3 getProposedSize();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptions.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptions.java
new file mode 100644
index 0000000..b427598
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptions.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.Consumer;
+
+import java.lang.annotation.Retention;
+import java.util.concurrent.Executor;
+
+/**
+ * Configuration options for reform (move/resize) UX. To create a ReformOptions instance, call
+ * {@code XrExtensions.createReformOptions()}.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ReformOptions {
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            flag = true,
+            value = {ALLOW_MOVE, ALLOW_RESIZE})
+    @Retention(SOURCE)
+    @interface AllowedReformTypes {}
+
+    // clang-format on
+
+    int ALLOW_MOVE = 1;
+    int ALLOW_RESIZE = 2;
+
+    /** Which reform actions are enabled. */
+    @AllowedReformTypes
+    int getEnabledReform();
+
+    /** By default, only ALLOW_MOVE is enabled. */
+    @NonNull
+    ReformOptions setEnabledReform(@AllowedReformTypes int enabledReform);
+
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            flag = true,
+            value = {
+                FLAG_SCALE_WITH_DISTANCE,
+                FLAG_ALLOW_SYSTEM_MOVEMENT,
+                FLAG_POSE_RELATIVE_TO_PARENT
+            })
+    @Retention(SOURCE)
+    public @interface ReformFlags {}
+
+    // clang-format on
+
+    int FLAG_SCALE_WITH_DISTANCE = 1;
+    int FLAG_ALLOW_SYSTEM_MOVEMENT = 2;
+    int FLAG_POSE_RELATIVE_TO_PARENT = 4;
+
+    /** Behaviour flags. */
+    @ReformFlags
+    int getFlags();
+
+    /** By default, the flags are set to 0. */
+    @NonNull
+    ReformOptions setFlags(@ReformFlags int flags);
+
+    /**
+     * Current size of the content, in meters. This is the local size (does not include any scale
+     * factors)
+     */
+    @NonNull
+    Vec3 getCurrentSize();
+
+    /** By default, the current size is set to (1, 1, 1). */
+    @NonNull
+    ReformOptions setCurrentSize(@NonNull Vec3 currentSize);
+
+    /** Minimum size of the content, in meters. This is a local size. */
+    @NonNull
+    Vec3 getMinimumSize();
+
+    /** By default, the minimum size is set to (1, 1, 1). */
+    @NonNull
+    ReformOptions setMinimumSize(@NonNull Vec3 minimumSize);
+
+    /** Maximum size of the content, in meters. This is a local size. */
+    @NonNull
+    Vec3 getMaximumSize();
+
+    /** By default, the maximum size is set to (1, 1, 1). */
+    @NonNull
+    ReformOptions setMaximumSize(@NonNull Vec3 maximumSize);
+
+    /** The aspect ratio of the content on resizing. <= 0.0f when there are no preferences. */
+    float getFixedAspectRatio();
+
+    /**
+     * The aspect ratio determined by taking the panel's width over its height. An aspect ratio
+     * value less than 0 will be ignored. A value <= 0.0f means there are no preferences.
+     *
+     * <p>This method does not immediately resize the entity. The new aspect ratio will be applied
+     * the next time the user resizes the entity through the reform UI. During this resize
+     * operation, the entity's current area will be preserved.
+     *
+     * <p>If a different resizing behavior is desired, such as fixing the width and adjusting the
+     * height, the client can manually resize the entity to the preferred dimensions before calling
+     * this method. No automatic resizing will occur when using the reform UI then.
+     */
+    @NonNull
+    ReformOptions setFixedAspectRatio(float fixedAspectRatio);
+
+    /** Returns the current value of forceShowResizeOverlay. */
+    default boolean getForceShowResizeOverlay() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * If forceShowResizeOverlay is set to true, the resize overlay will always be show (until
+     * forceShowResizeOverlay is changed to false). This can be used by apps to implement their own
+     * resize affordances.
+     */
+    @NonNull
+    default ReformOptions setForceShowResizeOverlay(boolean forceShowResizeOverlay) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /** Returns the callback that will receive reform events. */
+    @NonNull
+    Consumer<ReformEvent> getEventCallback();
+
+    /** Sets the callback that will receive reform events. */
+    @NonNull
+    ReformOptions setEventCallback(@NonNull Consumer<ReformEvent> callback);
+
+    /** Returns the executor that events will be handled on. */
+    @NonNull
+    Executor getEventExecutor();
+
+    /** Sets the executor that events will be handled on. */
+    @NonNull
+    ReformOptions setEventExecutor(@NonNull Executor executor);
+
+    // clang-format off
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(value = {SCALE_WITH_DISTANCE_MODE_DEFAULT, SCALE_WITH_DISTANCE_MODE_DMM})
+    @Retention(SOURCE)
+    @interface ScaleWithDistanceMode {}
+
+    // clang-format on
+
+    // The values MUST be identical to the ScalingCurvePreset enum for Spaceflinger in
+    // vendor/google/ix/sysui/proto/components/freeform_positioning_state.proto.
+    int SCALE_WITH_DISTANCE_MODE_DMM = 2;
+    int SCALE_WITH_DISTANCE_MODE_DEFAULT = 3;
+
+    /** Returns the current value of scaleWithDistanceMode. */
+    default @ScaleWithDistanceMode int getScaleWithDistanceMode() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * If scaleWithDistanceMode is set, and FLAG_SCALE_WITH_DISTANCE is also in use, the scale the
+     * system suggests (or automatically applies when FLAG_ALLOW_SYSTEM_MOVEMENT is also in use)
+     * follows scaleWithDistanceMode:
+     *
+     * <p>SCALE_WITH_DISTANCE_MODE_DEFAULT: The panel scales in the same way as home space mode.
+     * SCALE_WITH_DISTANCE_MODE_DMM: The panel scales in a way that the user-perceived panel size
+     * never changes.
+     *
+     * <p>When FLAG_SCALE_WITH_DISTANCE is not in use, scaleWithDistanceMode is ignored.
+     */
+    @NonNull
+    default ReformOptions setScaleWithDistanceMode(
+            @ScaleWithDistanceMode int scaleWithDistanceMode) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptionsImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptionsImpl.java
new file mode 100644
index 0000000..25c796b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/ReformOptionsImpl.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.xr.extensions.Consumer;
+
+import java.util.concurrent.Executor;
+
+class ReformOptionsImpl implements ReformOptions {
+    @NonNull final com.android.extensions.xr.node.ReformOptions mOptions;
+
+    ReformOptionsImpl(@NonNull com.android.extensions.xr.node.ReformOptions options) {
+        requireNonNull(options);
+        mOptions = options;
+    }
+
+    static class ReformEventImpl implements ReformEvent {
+        @NonNull final com.android.extensions.xr.node.ReformEvent mEvent;
+
+        ReformEventImpl(@NonNull com.android.extensions.xr.node.ReformEvent event) {
+            requireNonNull(event);
+            mEvent = event;
+        }
+
+        @Override
+        public int getType() {
+            return mEvent.getType();
+        }
+
+        @Override
+        public int getState() {
+            return mEvent.getState();
+        }
+
+        @Override
+        public int getId() {
+            return mEvent.getId();
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getInitialRayOrigin() {
+            return NodeTypeConverter.toLibrary(mEvent.getInitialRayOrigin());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getInitialRayDirection() {
+            return NodeTypeConverter.toLibrary(mEvent.getInitialRayDirection());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getCurrentRayOrigin() {
+            return NodeTypeConverter.toLibrary(mEvent.getCurrentRayOrigin());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getCurrentRayDirection() {
+            return NodeTypeConverter.toLibrary(mEvent.getCurrentRayDirection());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getProposedPosition() {
+            return NodeTypeConverter.toLibrary(mEvent.getProposedPosition());
+        }
+
+        @Override
+        @NonNull
+        public Quatf getProposedOrientation() {
+            return NodeTypeConverter.toLibrary(mEvent.getProposedOrientation());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getProposedScale() {
+            return NodeTypeConverter.toLibrary(mEvent.getProposedScale());
+        }
+
+        @Override
+        @NonNull
+        public Vec3 getProposedSize() {
+            return NodeTypeConverter.toLibrary(mEvent.getProposedSize());
+        }
+    }
+
+    @Override
+    public int getEnabledReform() {
+        return mOptions.getEnabledReform();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setEnabledReform(int enabledReform) {
+        mOptions.setEnabledReform(enabledReform);
+        return this;
+    }
+
+    @Override
+    public int getFlags() {
+        return mOptions.getFlags();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setFlags(@ReformFlags int flags) {
+        mOptions.setFlags(flags);
+        return this;
+    }
+
+    @Override
+    public @NonNull Vec3 getCurrentSize() {
+        return NodeTypeConverter.toLibrary(mOptions.getCurrentSize());
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setCurrentSize(@NonNull Vec3 currentSize) {
+        mOptions.setCurrentSize(NodeTypeConverter.toFramework(currentSize));
+        return this;
+    }
+
+    @Override
+    public @NonNull Vec3 getMinimumSize() {
+        return NodeTypeConverter.toLibrary(mOptions.getMinimumSize());
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setMinimumSize(@NonNull Vec3 minimumSize) {
+        mOptions.setMinimumSize(NodeTypeConverter.toFramework(minimumSize));
+        return this;
+    }
+
+    @Override
+    public @NonNull Vec3 getMaximumSize() {
+        return NodeTypeConverter.toLibrary(mOptions.getMaximumSize());
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setMaximumSize(@NonNull Vec3 maximumSize) {
+        mOptions.setMaximumSize(NodeTypeConverter.toFramework(maximumSize));
+        return this;
+    }
+
+    @Override
+    public float getFixedAspectRatio() {
+        return mOptions.getFixedAspectRatio();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setFixedAspectRatio(float fixedAspectRatio) {
+        mOptions.setFixedAspectRatio(fixedAspectRatio);
+        return this;
+    }
+
+    @Override
+    public boolean getForceShowResizeOverlay() {
+        return mOptions.getForceShowResizeOverlay();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setForceShowResizeOverlay(boolean forceShowResizeOverlay) {
+        mOptions.setForceShowResizeOverlay(forceShowResizeOverlay);
+        return this;
+    }
+
+    @Override
+    public @NonNull Consumer<ReformEvent> getEventCallback() {
+        return (event) -> {
+            mOptions.getEventCallback().accept(NodeTypeConverter.toFramework(event));
+        };
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setEventCallback(@NonNull Consumer<ReformEvent> callback) {
+        mOptions.setEventCallback(
+                (event) -> {
+                    callback.accept(NodeTypeConverter.toLibrary(event));
+                });
+        return this;
+    }
+
+    @Override
+    public @NonNull Executor getEventExecutor() {
+        return mOptions.getEventExecutor();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setEventExecutor(@NonNull Executor executor) {
+        mOptions.setEventExecutor(executor);
+        return this;
+    }
+
+    @Override
+    public @ScaleWithDistanceMode int getScaleWithDistanceMode() {
+        return mOptions.getScaleWithDistanceMode();
+    }
+
+    @Override
+    @NonNull
+    public ReformOptions setScaleWithDistanceMode(
+            @ScaleWithDistanceMode int scaleWithDistanceMode) {
+        mOptions.setScaleWithDistanceMode(scaleWithDistanceMode);
+        return this;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Vec3.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Vec3.java
new file mode 100644
index 0000000..b5604af
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/node/Vec3.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.node;
+
+import androidx.annotation.RestrictTo;
+
+/** 3D vector. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Vec3 {
+    public Vec3(float x, float y, float z) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    public float x;
+    public float y;
+    public float z;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/passthrough/PassthroughState.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/passthrough/PassthroughState.java
new file mode 100644
index 0000000..d003129
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/passthrough/PassthroughState.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.passthrough;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Allows to configure the passthrough when the application is in full-space mode. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PassthroughState {
+    /** Passthrough mode to be used. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                PASSTHROUGH_MODE_OFF,
+                PASSTHROUGH_MODE_MAX,
+                PASSTHROUGH_MODE_MIN,
+            })
+    @Retention(SOURCE)
+    public @interface Mode {}
+
+    /** Node does not contribute to the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_OFF = 0;
+
+    /** Node maximizes the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_MAX = 1;
+
+    /** Node minimizes the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_MIN = 2;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanel.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanel.java
new file mode 100644
index 0000000..5eda03b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanel.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.node.Node;
+
+/**
+ * Defines the panel in XR scene to support embedding activities within a host activity.
+ *
+ * <p>When the host activity is destroyed, all the activities in its embedded {@link ActivityPanel}
+ * will also be destroyed.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ActivityPanel {
+    /**
+     * Launches an activity into this panel.
+     *
+     * @param intent the {@link Intent} to start.
+     * @param options additional options for how the Activity should be started.
+     */
+    void launchActivity(@NonNull Intent intent, @Nullable Bundle options);
+
+    /**
+     * Moves an existing activity into this panel.
+     *
+     * @param activity the {@link Activity} to move.
+     */
+    void moveActivity(@NonNull Activity activity);
+
+    /**
+     * Gets the node associated with this {@link ActivityPanel}.
+     *
+     * <p>The {@link ActivityPanel} can only be shown to the user after this node is attached to the
+     * host activity's scene.
+     *
+     * @see androidx.xr.extensions.XrExtensions#attachSpatialScene
+     */
+    @NonNull
+    Node getNode();
+
+    /**
+     * Updates the 2D window bounds of this {@link ActivityPanel}.
+     *
+     * <p>If the new bounds are smaller that the minimum dimensions of the activity embedded in this
+     * ActivityPanel, the ActivityPanel bounds will be reset to match the host Activity bounds.
+     *
+     * @param windowBounds the new 2D window bounds in the host container window coordinates.
+     */
+    void setWindowBounds(@NonNull Rect windowBounds);
+
+    /**
+     * Deletes the activity panel. All the activities in this {@link ActivityPanel} will also be
+     * destroyed.
+     */
+    void delete();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelImpl.java
new file mode 100644
index 0000000..1d6925c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelImpl.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTypeConverter;
+
+/** Implementation of {@link ActivityPanel}. */
+final class ActivityPanelImpl implements ActivityPanel {
+    @NonNull final com.android.extensions.xr.space.ActivityPanel mActivityPanel;
+
+    ActivityPanelImpl(@NonNull com.android.extensions.xr.space.ActivityPanel panel) {
+        mActivityPanel = panel;
+    }
+
+    @Override
+    public void launchActivity(@NonNull Intent intent, @Nullable Bundle options) {
+        mActivityPanel.launchActivity(intent, options);
+    }
+
+    @Override
+    public void moveActivity(@NonNull Activity activity) {
+        mActivityPanel.moveActivity(activity);
+    }
+
+    @Override
+    @NonNull
+    public Node getNode() {
+        return NodeTypeConverter.toLibrary(mActivityPanel.getNode());
+    }
+
+    @Override
+    public void setWindowBounds(@NonNull Rect windowBounds) {
+        mActivityPanel.setWindowBounds(windowBounds);
+    }
+
+    @Override
+    public void delete() {
+        mActivityPanel.delete();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelLaunchParameters.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelLaunchParameters.java
new file mode 100644
index 0000000..1c8d5d1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/ActivityPanelLaunchParameters.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import android.graphics.Rect;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Defines the launch parameters when creating an {@link ActivityPanel}. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class ActivityPanelLaunchParameters {
+    /** 2D window bounds in the host container window coordinates. */
+    private final @NonNull Rect mWindowBounds;
+
+    /**
+     * Constructs an {@link ActivityPanelLaunchParameters} with the given initial window bounds.
+     *
+     * @param windowBounds the initial 2D window bounds of the panel, which will be the bounds of
+     *     the Activity launched into the {@link ActivityPanel}.
+     */
+    public ActivityPanelLaunchParameters(@NonNull Rect windowBounds) {
+        mWindowBounds = windowBounds;
+    }
+
+    /**
+     * @return the initial 2D window bounds.
+     */
+    public @NonNull Rect getWindowBounds() {
+        return mWindowBounds;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/Bounds.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/Bounds.java
new file mode 100644
index 0000000..4ec1f6f
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/Bounds.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+
+import java.util.Objects;
+
+/**
+ * Bounds values in meters.
+ *
+ * @see androidx.xr.extensions.XrExtensions#getSpatialState
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Bounds {
+    public final float width;
+    public final float height;
+    public final float depth;
+
+    public Bounds(float width, float height, float depth) {
+        this.width = width;
+        this.height = height;
+        this.depth = depth;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || !(other instanceof Bounds)) {
+            return false;
+        }
+        Bounds impl = (Bounds) other;
+        return width == impl.width && height == impl.height && depth == impl.depth;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(width, height, depth);
+    }
+
+    @Override
+    public String toString() {
+        return "{width=" + width + ", height=" + height + ", depth=" + depth + "}";
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/BoundsChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/BoundsChangeEvent.java
new file mode 100644
index 0000000..faa719d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/BoundsChangeEvent.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when there is a bounds change. For example, resize the panel in home space, or
+ * enter/exit FSM.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class BoundsChangeEvent extends SpatialStateEvent {
+    /** Width of the bounds in meters. */
+    public float width;
+
+    /** Height of the bounds in meters. */
+    public float height;
+
+    /** Depth of the bounds in meters. */
+    public float depth;
+
+    /** Bounds in meters. */
+    public Bounds bounds;
+
+    public BoundsChangeEvent(Bounds bounds) {
+        this.bounds = bounds;
+        this.width = bounds.width;
+        this.height = bounds.height;
+        this.depth = bounds.depth;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentControlChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentControlChangeEvent.java
new file mode 100644
index 0000000..485760f
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentControlChangeEvent.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when the ability to control the environment changes.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class EnvironmentControlChangeEvent extends SpatialStateEvent {
+    /** Whether or not the receiver can control the environment. */
+    public boolean environmentControlAllowed;
+
+    public EnvironmentControlChangeEvent(boolean allowed) {
+        this.environmentControlAllowed = allowed;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentVisibilityChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentVisibilityChangeEvent.java
new file mode 100644
index 0000000..3f5fbd6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/EnvironmentVisibilityChangeEvent.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+
+/**
+ * For all resumed top activities with this spatialstate callback set, this is called whenever the
+ * VR background changes. This is also called when an activity becomes top resumed.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class EnvironmentVisibilityChangeEvent extends SpatialStateEvent {
+    /** Visibility state of the VR background */
+    public @EnvironmentVisibilityState.State int environmentState;
+
+    public EnvironmentVisibilityChangeEvent(@EnvironmentVisibilityState.State int state) {
+        this.environmentState = state;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/HitTestResult.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/HitTestResult.java
new file mode 100644
index 0000000..cc34e36
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/HitTestResult.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.node.Vec3;
+
+import java.lang.annotation.Retention;
+
+/**
+ * Hit test result.
+ *
+ * @see androidx.xr.extensions.XrExtensions#hitTest
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class HitTestResult {
+    /** Distance from the ray origin to the hit position. */
+    public float distance;
+
+    /** The hit position in task coordinates. */
+    public @NonNull Vec3 hitPosition;
+
+    /** Normal of the surface at the collision point, if known. */
+    public @Nullable Vec3 surfaceNormal;
+
+    /** Hit surface types. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(value = {SURFACE_UNKNOWN, SURFACE_PANEL, SURFACE_3D_OBJECT})
+    @Retention(SOURCE)
+    public @interface SurfaceType {}
+
+    public static final int SURFACE_UNKNOWN = 0;
+    public static final int SURFACE_PANEL = 1;
+    public static final int SURFACE_3D_OBJECT = 2;
+
+    /** The type of surface that was hit. */
+    public @SurfaceType int surfaceType;
+
+    /** Whether or not the virtual background environment is visible. */
+    public boolean virtualEnvironmentIsVisible;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/PassthroughVisibilityChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/PassthroughVisibilityChangeEvent.java
new file mode 100644
index 0000000..c29176e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/PassthroughVisibilityChangeEvent.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+
+/**
+ * For all resumed top activities with this spatialstate callback set, this is called whenever the
+ * Passthrough state changes. This is also called when an activity becomes top resumed.
+ * TODO(runping): Ask the privacy team if it's okay to send this event in HSM.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class PassthroughVisibilityChangeEvent extends SpatialStateEvent {
+    /** Visibility state of Passthrough. */
+    public @PassthroughVisibilityState.State int passthroughState;
+
+    public PassthroughVisibilityChangeEvent(@PassthroughVisibilityState.State int state) {
+        this.passthroughState = state;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpaceTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpaceTypeConverter.java
new file mode 100644
index 0000000..0b7d2d8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpaceTypeConverter.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.node.NodeTypeConverter;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class SpaceTypeConverter {
+
+    private SpaceTypeConverter() {}
+
+    /**
+     * Converts a {@link Bounds} to a framework type.
+     *
+     * @param bounds The {@link Bounds} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.space.Bounds}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.space.Bounds toFramework(@NonNull Bounds bounds) {
+        requireNonNull(bounds);
+
+        return new com.android.extensions.xr.space.Bounds(
+                bounds.width, bounds.height, bounds.depth);
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.Bounds} to a library type.
+     *
+     * @param bounds The {@link com.android.extensions.xr.space.Bounds} to convert.
+     * @return The library type of the {@link Bounds}.
+     */
+    @NonNull
+    public static Bounds toLibrary(@NonNull com.android.extensions.xr.space.Bounds bounds) {
+        requireNonNull(bounds);
+
+        return new Bounds(bounds.getWidth(), bounds.getHeight(), bounds.getDepth());
+    }
+
+    /**
+     * Converts a {@link SpatialCapabilities} to a framework type.
+     *
+     * @param capabilities The {@link SpatialCapabilities} to convert.
+     * @return The framework type of the {@link
+     *     com.android.extensions.xr.space.SpatialCapabilities}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.space.SpatialCapabilities toFramework(
+            @NonNull SpatialCapabilitiesImpl capabilities) {
+        requireNonNull(capabilities);
+
+        return capabilities.mCapabilities;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.SpatialCapabilities} to a library type.
+     *
+     * @param capabilities The {@link com.android.extensions.xr.space.SpatialCapabilities} to
+     *     convert.
+     * @return The library type of the {@link SpatialCapabilities}.
+     */
+    @NonNull
+    public static SpatialCapabilitiesImpl toLibrary(
+            @NonNull com.android.extensions.xr.space.SpatialCapabilities capabilities) {
+        requireNonNull(capabilities);
+
+        return new SpatialCapabilitiesImpl(capabilities);
+    }
+
+    /**
+     * Converts a {@link HitTestResult} to a framework type.
+     *
+     * @param result The {@link HitTestResult} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.space.HitTestResult}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.space.HitTestResult toFramework(
+            @NonNull HitTestResult result) {
+        requireNonNull(result);
+
+        com.android.extensions.xr.space.HitTestResult.Builder builder =
+                new com.android.extensions.xr.space.HitTestResult.Builder(
+                        result.distance,
+                        NodeTypeConverter.toFramework(result.hitPosition),
+                        result.virtualEnvironmentIsVisible,
+                        result.surfaceType);
+
+        if (result.surfaceNormal != null) {
+            builder.setSurfaceNormal(NodeTypeConverter.toFramework(result.surfaceNormal));
+        }
+
+        return builder.build();
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.HitTestResult} to a library type.
+     *
+     * @param result The {@link com.android.extensions.xr.space.HitTestResult} to convert.
+     * @return The library type of the {@link HitTestResult}.
+     */
+    @NonNull
+    public static HitTestResult toLibrary(
+            @NonNull com.android.extensions.xr.space.HitTestResult result) {
+        requireNonNull(result);
+
+        HitTestResult hitTestResult = new HitTestResult();
+        hitTestResult.distance = result.getDistance();
+        hitTestResult.hitPosition = NodeTypeConverter.toLibrary(result.getHitPosition());
+        if (result.getSurfaceNormal() != null) {
+            hitTestResult.surfaceNormal = NodeTypeConverter.toLibrary(result.getSurfaceNormal());
+        }
+        hitTestResult.surfaceType = result.getSurfaceType();
+        hitTestResult.virtualEnvironmentIsVisible = result.getVirtualEnvironmentIsVisible();
+
+        return hitTestResult;
+    }
+
+    /**
+     * Converts a {@link ActivityPanel} to a framework type.
+     *
+     * @param panel The {@link ActivityPanel} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.space.ActivityPanel}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.space.ActivityPanel toFramework(
+            @NonNull ActivityPanelImpl panel) {
+        requireNonNull(panel);
+
+        return panel.mActivityPanel;
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.ActivityPanel} to a library type.
+     *
+     * @param panel The {@link com.android.extensions.xr.space.ActivityPanel} to convert.
+     * @return The library type of the {@link ActivityPanel}.
+     */
+    @NonNull
+    public static ActivityPanelImpl toLibrary(
+            @NonNull com.android.extensions.xr.space.ActivityPanel panel) {
+        requireNonNull(panel);
+
+        return new ActivityPanelImpl(panel);
+    }
+
+    /**
+     * Converts a {@link ActivityPanelLaunchParameters} to a framework type.
+     *
+     * @param params The {@link ActivityPanelLaunchParameters} to convert.
+     * @return The framework type of the {@link
+     *     com.android.extensions.xr.space.ActivityPanelLaunchParameters}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.space.ActivityPanelLaunchParameters toFramework(
+            @NonNull ActivityPanelLaunchParameters params) {
+        requireNonNull(params);
+
+        return new com.android.extensions.xr.space.ActivityPanelLaunchParameters(
+                params.getWindowBounds());
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.ActivityPanelLaunchParameters} to a library
+     * type.
+     *
+     * @param params The {@link com.android.extensions.xr.space.ActivityPanelLaunchParameters} to
+     *     convert.
+     * @return The library type of the {@link ActivityPanelLaunchParameters}.
+     */
+    @NonNull
+    public static ActivityPanelLaunchParameters toLibrary(
+            @NonNull com.android.extensions.xr.space.ActivityPanelLaunchParameters params) {
+        requireNonNull(params);
+
+        return new ActivityPanelLaunchParameters(params.getWindowBounds());
+    }
+
+    /**
+     * Converts a {@link SpatialState} to a library type.
+     *
+     * @param state The {@link SpatialState} to convert.
+     * @return The library type of the {@link SpatialState}.
+     */
+    @NonNull
+    public static SpatialState toLibrary(
+            @NonNull com.android.extensions.xr.space.SpatialState state) {
+        requireNonNull(state);
+
+        return new SpatialStateImpl(state);
+    }
+
+    /**
+     * Converts a {@link SpatialStateEvent} to a library type.
+     *
+     * @param event The {@link SpatialStateEvent} to convert.
+     * @return The library type of the {@link SpatialStateEvent}.
+     */
+    @NonNull
+    public static SpatialStateEvent toLibrary(
+            @NonNull com.android.extensions.xr.space.SpatialStateEvent event) {
+        requireNonNull(event);
+
+        if (event instanceof com.android.extensions.xr.space.BoundsChangeEvent) {
+            return toLibrary((com.android.extensions.xr.space.BoundsChangeEvent) event);
+        } else if (event instanceof com.android.extensions.xr.space.EnvironmentControlChangeEvent) {
+            return toLibrary((com.android.extensions.xr.space.EnvironmentControlChangeEvent) event);
+        } else if (event
+                instanceof com.android.extensions.xr.space.EnvironmentVisibilityChangeEvent) {
+            return toLibrary(
+                    (com.android.extensions.xr.space.EnvironmentVisibilityChangeEvent) event);
+        } else if (event instanceof com.android.extensions.xr.space.SpatialCapabilityChangeEvent) {
+            return toLibrary((com.android.extensions.xr.space.SpatialCapabilityChangeEvent) event);
+        }
+
+        // TODO(bvanderlaan): Handle this error better.
+        throw new IllegalArgumentException("Unknown event type " + event);
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.BoundsChangeEvent} to a library type.
+     *
+     * @param event The {@link com.android.extensions.xr.space.BoundsChangeEvent} to convert.
+     * @return The library type of the {@link BoundsChangeEvent}.
+     */
+    @NonNull
+    public static SpatialStateEvent toLibrary(
+            @NonNull com.android.extensions.xr.space.BoundsChangeEvent event) {
+        requireNonNull(event);
+
+        return new BoundsChangeEvent(toLibrary(event.getBounds()));
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.EnvironmentControlChangeEvent} to a library
+     * type.
+     *
+     * @param event The {@link com.android.extensions.xr.space.EnvironmentControlChangeEvent} to
+     *     convert.
+     * @return The library type of the {@link EnvironmentControlChangeEvent}.
+     */
+    @NonNull
+    public static SpatialStateEvent toLibrary(
+            @NonNull com.android.extensions.xr.space.EnvironmentControlChangeEvent event) {
+        requireNonNull(event);
+
+        return new EnvironmentControlChangeEvent(event.getEnvironmentControlAllowed());
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.EnvironmentVisibilityChangeEvent} to a
+     * library type.
+     *
+     * @param event The {@link com.android.extensions.xr.space.EnvironmentVisibilityChangeEvent} to
+     *     convert.
+     * @return The library type of the {@link EnvironmentVisibilityChangeEvent}.
+     */
+    @NonNull
+    public static SpatialStateEvent toLibrary(
+            @NonNull com.android.extensions.xr.space.EnvironmentVisibilityChangeEvent event) {
+        requireNonNull(event);
+
+        return new EnvironmentVisibilityChangeEvent(event.getEnvironmentState());
+    }
+
+    /**
+     * Converts a {@link com.android.extensions.xr.space.SpatialCapabilityChangeEvent} to a library
+     * type.
+     *
+     * @param event The {@link com.android.extensions.xr.space.SpatialCapabilityChangeEvent} to
+     *     convert.
+     * @return The library type of the {@link SpatialCapabilityChangeEvent}.
+     */
+    @NonNull
+    public static SpatialStateEvent toLibrary(
+            @NonNull com.android.extensions.xr.space.SpatialCapabilityChangeEvent event) {
+        requireNonNull(event);
+
+        return new SpatialCapabilityChangeEvent(toLibrary(event.getCurrentCapabilities()));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilities.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilities.java
new file mode 100644
index 0000000..3ec23c8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilities.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+
+/** Represents a set of capabilities an activity has. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialCapabilities {
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(
+            value = {
+                SPATIAL_UI_CAPABLE,
+                SPATIAL_3D_CONTENTS_CAPABLE,
+                PASSTHROUGH_CONTROL_CAPABLE,
+                APP_ENVIRONMENTS_CAPABLE,
+                SPATIAL_AUDIO_CAPABLE,
+                SPATIAL_ACTIVITY_EMBEDDING_CAPABLE
+            })
+    @Retention(SOURCE)
+    @interface CapabilityType {}
+
+    /**
+     * The activity can spatialize itself by adding a spatial panel.
+     *
+     * <p>This capability allows neither 3D content creation nor spatial activity embedding.
+     */
+    int SPATIAL_UI_CAPABLE = 0;
+
+    /**
+     * The activity can create 3D contents.
+     *
+     * <p>This capability allows neither spatial panel creation nor spatial activity embedding.
+     */
+    int SPATIAL_3D_CONTENTS_CAPABLE = 1;
+
+    /** The activity can enable or disable passthrough. */
+    int PASSTHROUGH_CONTROL_CAPABLE = 2;
+
+    /** The activity can set its own environment. */
+    int APP_ENVIRONMENTS_CAPABLE = 3;
+
+    /** The activity can use spatial audio. */
+    int SPATIAL_AUDIO_CAPABLE = 4;
+
+    /**
+     * The activity can launch another activity on a spatial panel to spatially embed it.
+     *
+     * <p>This capability allows neither spatial panel creation nor 3D content creation.
+     */
+    int SPATIAL_ACTIVITY_EMBEDDING_CAPABLE = 5;
+
+    /** Returns true if the capability is available. */
+    default boolean get(@CapabilityType int capability) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilitiesImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilitiesImpl.java
new file mode 100644
index 0000000..d9640d1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilitiesImpl.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.NonNull;
+
+final class SpatialCapabilitiesImpl implements SpatialCapabilities {
+    @NonNull final com.android.extensions.xr.space.SpatialCapabilities mCapabilities;
+
+    SpatialCapabilitiesImpl() {
+        mCapabilities = new com.android.extensions.xr.space.SpatialCapabilities();
+    }
+
+    SpatialCapabilitiesImpl(
+            @NonNull com.android.extensions.xr.space.SpatialCapabilities capabilities) {
+        mCapabilities = capabilities;
+    }
+
+    @Override
+    public boolean get(@CapabilityType int capability) {
+        return mCapabilities.get(capability);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || getClass() != other.getClass()) {
+            return false;
+        }
+        SpatialCapabilitiesImpl impl = (SpatialCapabilitiesImpl) other;
+        return mCapabilities.equals(impl.mCapabilities);
+    }
+
+    @Override
+    public int hashCode() {
+        return mCapabilities.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return mCapabilities.toString();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilityChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilityChangeEvent.java
new file mode 100644
index 0000000..160a1fe
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialCapabilityChangeEvent.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when the spatial capability set has changed.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class SpatialCapabilityChangeEvent extends SpatialStateEvent {
+    public SpatialCapabilities currentCapabilities;
+
+    public SpatialCapabilityChangeEvent(SpatialCapabilities capabilities) {
+        this.currentCapabilities = capabilities;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialState.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialState.java
new file mode 100644
index 0000000..4ccaffa
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialState.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import static androidx.xr.extensions.XrExtensions.IMAGE_TOO_OLD;
+
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.Node;
+
+/**
+ * An interface that represents an activity's spatial state.
+ *
+ * <p>An object of the class is effectively immutable. Once the object, which is a "snapshot" of the
+ * activity's spatial state, is returned to the client, each getters will always return the same
+ * value even if the activity's state later changes.
+ *
+ * @see androidx.xr.extensions.XrExtensions#registerSpatialStateCallback
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatialState {
+    /**
+     * Gets spatial bounds of the activity. When in full space mode, (infinity, infinity, infinity)
+     * is returned.
+     *
+     * @see androidx.xr.extensions.space.Bounds
+     */
+    default @NonNull Bounds getBounds() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets spatial capabilities of the activity. Unlike other capabilities in Android, this may
+     * dynamically change based on the current mode the activity is in, whether the activity is the
+     * top one in its task, whether the task is the top visible one on the desktop, and so on.
+     *
+     * @see androidx.xr.extensions.space.SpatialCapabilities
+     */
+    default @NonNull SpatialCapabilities getSpatialCapabilities() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the environment visibility of the activity.
+     *
+     * @see androidx.xr.extensions.environment.EnvironmentVisibilityState
+     */
+    default @NonNull EnvironmentVisibilityState getEnvironmentVisibility() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the passthrough visibility of the activity.
+     *
+     * @see androidx.xr.extensions.environment.PassthroughVisibilityState
+     */
+    default @NonNull PassthroughVisibilityState getPassthroughVisibility() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * True if the scene node that is currently in use (if any) is the same as targetNode. When
+     * targetNode is null, this API returns true when no scene node is currently in use (i.e. the
+     * activity is not SPATIAL_UI_CAPABLE, the activity hasn't called attachSpatialScene API at all,
+     * or the activity hasn't called it again since the last detachSpatialScene API call.)
+     *
+     * @see androidx.xr.extensions.attachSpatialScene
+     */
+    default boolean isActiveSceneNode(@Nullable Node targetNode) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * True if the window leash node that is currently in use (if any) is the same as targetNode.
+     * When targetNode is null, this API returns true when no window leash node is currently in use
+     * (i.e. the activity is not SPATIAL_UI_CAPABLE, the activity hasn't called attachSpatialScene
+     * API at all, or the activity hasn't called it again since the last detachSpatialScene API
+     * call.)
+     *
+     * @see androidx.xr.extensions.attachSpatialScene
+     */
+    default boolean isActiveWindowLeashNode(@Nullable Node targetNode) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * True if the environment node that is currently in use (if any) is the same as targetNode.
+     * When targetNode is null, this API returns true when no environment node is currently in use
+     * (i.e. the activity is not APP_ENVIRONMENTS_CAPABLE, the activity hasn't called
+     * attachSpatialEnvironment API at all, or the activity hasn't called it again since the last
+     * detachSpatialEnvironment API call.)
+     *
+     * <p>Note that as a special case, when isEnvironmentInherited() is true, the API returns false
+     * for a null targetNode even if your activity hasn't called attachSpatialEnvironment yet.
+     *
+     * @see androidx.xr.extensions.attachSpatialEnvironment
+     * @see androidx.xr.extensions.setFullSpaceModeWithEnvironmentInherited
+     */
+    default boolean isActiveEnvironmentNode(@Nullable Node targetNode) {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * True if an activity-provided environment node is currently in use, and the node is one
+     * inherited from a different activity.
+     *
+     * @see androidx.xr.extensions.attachSpatialEnvironment
+     * @see androidx.xr.extensions.setFullSpaceModeWithEnvironmentInherited
+     */
+    default boolean isEnvironmentInherited() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the main window size. (0, 0) is returned when the activity is not SPATIAL_UI_CAPABLE.
+     *
+     * <p>When the activity is not SPATIAL_UI_CAPABLE, use android.content.res.Configuration to
+     * obtain the activity's size.
+     *
+     * @see androidx.xr.extensions.setMainWindowSize
+     * @see android.content.res.Configuration
+     */
+    @NonNull
+    default Size getMainWindowSize() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+
+    /**
+     * Gets the main window's aspect ratio preference. 0.0f is returned when there's no preference
+     * set via setPreferredAspectRatio API, or the activity is currently SPATIAL_UI_CAPABLE.
+     *
+     * <p>When SPATIAL_UI_CAPABLE, activities can set a preferred aspect ratio via ReformOptions,
+     * but that reform options setting won't be reflected to the value returned from this API.
+     *
+     * @see androidx.xr.extensions.setPreferredAspectRatio
+     * @see androidx.xr.extensions.node.ReformOptions
+     */
+    default float getPreferredAspectRatio() {
+        throw new UnsupportedOperationException(IMAGE_TOO_OLD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateEvent.java
new file mode 100644
index 0000000..0173a22
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateEvent.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Base class for spatial state change events.
+ *
+ * @see androidx.xr.extensions.XrExtensions#setSpatialStateCallback
+ * @deprecated Use SpatialState instead.
+ */
+@Deprecated
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class SpatialStateEvent {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateImpl.java
new file mode 100644
index 0000000..b33ac5c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/SpatialStateImpl.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.environment.EnvironmentTypeConverter;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTypeConverter;
+
+class SpatialStateImpl implements SpatialState {
+    @NonNull private final com.android.extensions.xr.space.SpatialState mState;
+
+    SpatialStateImpl(@NonNull com.android.extensions.xr.space.SpatialState state) {
+        mState = state;
+    }
+
+    @Override
+    public @NonNull Bounds getBounds() {
+        return SpaceTypeConverter.toLibrary(mState.getBounds());
+    }
+
+    @Override
+    public @NonNull SpatialCapabilities getSpatialCapabilities() {
+        return SpaceTypeConverter.toLibrary(mState.getSpatialCapabilities());
+    }
+
+    @Override
+    public @NonNull EnvironmentVisibilityState getEnvironmentVisibility() {
+        return EnvironmentTypeConverter.toLibrary(mState.getEnvironmentVisibility());
+    }
+
+    @Override
+    public @NonNull PassthroughVisibilityState getPassthroughVisibility() {
+        return EnvironmentTypeConverter.toLibrary(mState.getPassthroughVisibility());
+    }
+
+    @Override
+    public boolean isActiveSceneNode(@Nullable Node targetNode) {
+        return mState.isActiveSceneNode(NodeTypeConverter.toFramework(targetNode));
+    }
+
+    @Override
+    public boolean isActiveWindowLeashNode(@Nullable Node targetNode) {
+        return mState.isActiveWindowLeashNode(NodeTypeConverter.toFramework(targetNode));
+    }
+
+    @Override
+    public boolean isActiveEnvironmentNode(@Nullable Node targetNode) {
+        return mState.isActiveEnvironmentNode(NodeTypeConverter.toFramework(targetNode));
+    }
+
+    @Override
+    public boolean isEnvironmentInherited() {
+        return mState.isEnvironmentInherited();
+    }
+
+    @Override
+    public @NonNull Size getMainWindowSize() {
+        return mState.getMainWindowSize();
+    }
+
+    @Override
+    public float getPreferredAspectRatio() {
+        return mState.getPreferredAspectRatio();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || !(other instanceof SpatialStateImpl)) {
+            return false;
+        }
+
+        SpatialStateImpl impl = (SpatialStateImpl) other;
+        return mState.equals(impl.mState);
+    }
+
+    @Override
+    public int hashCode() {
+        return mState.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return mState.toString();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/VisibilityChangeEvent.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/VisibilityChangeEvent.java
new file mode 100644
index 0000000..e1fe354
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/space/VisibilityChangeEvent.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.space;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.ExperimentalExtensionApi;
+
+import java.lang.annotation.Retention;
+
+/**
+ * Triggers when there is a change to spatial visibility. For example, user looks away from the
+ * activity.
+ *
+ * @deprecated Not used anymore.
+ */
+@Deprecated
+@ExperimentalExtensionApi
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class VisibilityChangeEvent extends SpatialStateEvent {
+    /** Possible visibility values for an activity. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @IntDef(value = {UNKNOWN, HIDDEN, PARTIALLY_VISIBLE, VISIBLE})
+    @Retention(SOURCE)
+    public @interface SpatialVisibility {}
+
+    /** The visibility of this activity is not known. */
+    public static final int UNKNOWN = 0;
+
+    /** This activity is hidden outside of the user's Field of View. */
+    public static final int HIDDEN = 1;
+
+    /** Some, but not all, of the activity is within the user's Field of View. */
+    public static final int PARTIALLY_VISIBLE = 2;
+
+    /** The entirety of the activity is within the user's Field of View. */
+    public static final int VISIBLE = 3;
+
+    public @SpatialVisibility int visibility;
+
+    public VisibilityChangeEvent(@SpatialVisibility int visibility) {
+        this.visibility = visibility;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridge.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridge.java
new file mode 100644
index 0000000..512dfcf
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridge.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.splitengine;
+
+import androidx.annotation.RestrictTo;
+
+/** Wrapper object around a native SplitEngineBridge. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SplitEngineBridge {
+    SplitEngineBridge() {}
+    ;
+
+    /**
+     * Opaque token to split engine bridge
+     *
+     * <p>This will be read/set by native code inside Impress via JNI.
+     *
+     * <p>JNI does not respect access modifies so this private field is publicly accessible to the
+     * native impress code.
+     *
+     * <p>This field is read from and written to by: *
+     * vendor/google/imp/core/split_engine/android/view/split_engine_jni.cc *
+     * google3/third_party/impress/core/split_engine/android/view/split_engine_jni.cc *
+     * frameworks/base/libs/xr/Jetpack/jni/android_xr_splitengine_extension.cpp
+     *
+     * <p>The latter accesses this field through the implementation of the SplitEngineBridge
+     * interface.
+     */
+    @SuppressWarnings("unused")
+    private long mNativeHandle;
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridgeImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridgeImpl.java
new file mode 100644
index 0000000..7f07b96
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineBridgeImpl.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.splitengine;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.lang.reflect.Field;
+
+/** Implementation of SplitEngineBridge which delegates to native Impress implementation. */
+class SplitEngineBridgeImpl extends SplitEngineBridge {
+    final com.android.extensions.xr.splitengine.SplitEngineBridge mBridge;
+
+    SplitEngineBridgeImpl(@NonNull com.android.extensions.xr.splitengine.SplitEngineBridge bridge) {
+        mBridge = bridge;
+        setNativeHandle();
+    }
+
+    /**
+     * Set the shared memory split engine bridge handle.
+     *
+     * <p>The {@link mNativeHandle} field is defined as a private field on the extensions library
+     * which we would like to not change for backward compatibility. This field is read by
+     * application code and needs to contain the handle provided by the platform JNI component which
+     * is accessible from {@link com.android.extensions.xr.splitengine.SplitEngineBridge}. In order
+     * to be able to access this private field we need to use reflection.
+     *
+     * <p>This method will access the handle from the platform instance of SplitEngineBridge and
+     * store it in the private {@link mNativeHandle} field defined by the library API.
+     */
+    private void setNativeHandle() {
+        try {
+            Field privateField = SplitEngineBridge.class.getDeclaredField("mNativeHandle");
+            privateField.setAccessible(true);
+
+            privateField.set(this, mBridge.getNativeHandle());
+        } catch (NoSuchFieldException | IllegalAccessException e) {
+            Log.e("SplitEngineBridge", "Failed to set native handle", e);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineTypeConverter.java
new file mode 100644
index 0000000..d1c16c31
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/splitengine/SplitEngineTypeConverter.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.splitengine;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class SplitEngineTypeConverter {
+
+    private SplitEngineTypeConverter() {}
+
+    /**
+     * Converts a {@link com.android.extensions.xr.splitengine.SplitEngineBridge} to a library type.
+     *
+     * @param bridge The {@link com.android.extensions.xr.splitengine.SplitEngineBridge} to convert.
+     * @return The library type of the {@link SplitEngineBridge}.
+     */
+    @NonNull
+    public static SplitEngineBridge toLibrary(
+            @NonNull com.android.extensions.xr.splitengine.SplitEngineBridge bridge) {
+        requireNonNull(bridge);
+
+        return new SplitEngineBridgeImpl(bridge);
+    }
+
+    /**
+     * Converts a {@link SplitEngineBridge} to a framework type.
+     *
+     * @param bridge The {@link SplitEngineBridge} to convert.
+     * @return The framework type of the {@link
+     *     com.android.extensions.xr.splitengine.SplitEngineBridge}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.splitengine.SplitEngineBridge toFramework(
+            @NonNull SplitEngineBridge bridge) {
+        requireNonNull(bridge);
+
+        return ((SplitEngineBridgeImpl) bridge).mBridge;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/Subspace.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/Subspace.java
new file mode 100644
index 0000000..00be596
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/Subspace.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.subspace;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Handle to a subspace in the system scene graph.
+ *
+ * <p>A subspace by itself does not have any visual representation. It merely defines a local space
+ * in its parent space. Once created, 3D content can be rendered in the hierarchy of that subspace.
+ *
+ * <p>Note that {@link Subspace} uses a right-hand coordinate system, i.e. +X points to the right,
+ * +Y up, and +Z points towards the camera.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Subspace {}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceImpl.java
new file mode 100644
index 0000000..06ebe1e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceImpl.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.subspace;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Handle to a subspace in the system scene graph.
+ *
+ * <p>A subspace by itself does not have any visual representation. It merely defines a local space
+ * in its parent space. Once created, 3D content can be rendered in the hierarchy of that subspace.
+ *
+ * <p>Note that {@link Subspace} uses a right-hand coordinate system, i.e. +X points to the right,
+ * +Y up, and +Z points towards the camera.
+ */
+class SubspaceImpl implements Subspace {
+    final com.android.extensions.xr.subspace.Subspace mSubspace;
+
+    /** Creates a new empty subspace. */
+    SubspaceImpl(@NonNull com.android.extensions.xr.subspace.Subspace subspace) {
+        mSubspace = subspace;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceTypeConverter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceTypeConverter.java
new file mode 100644
index 0000000..847616b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/extensions/subspace/SubspaceTypeConverter.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2024 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 androidx.xr.extensions.subspace;
+
+import static java.util.Objects.requireNonNull;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** This class is able to convert library types into platform types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class SubspaceTypeConverter {
+
+    private SubspaceTypeConverter() {}
+
+    /**
+     * Converts a {@link com.android.extensions.xr.subspace.Subspace} to a library type.
+     *
+     * @param subspace The {@link com.android.extensions.xr.subspace.Subspace} to convert.
+     * @return The library type of the {@link Subspace}.
+     */
+    @NonNull
+    public static Subspace toLibrary(
+            @NonNull com.android.extensions.xr.subspace.Subspace subspace) {
+        requireNonNull(subspace);
+
+        return new SubspaceImpl(subspace);
+    }
+
+    /**
+     * Converts a {@link Subspace} to a framework type.
+     *
+     * @param subspace The {@link Subspace} to convert.
+     * @return The framework type of the {@link com.android.extensions.xr.subspace.Subspace}.
+     */
+    @NonNull
+    public static com.android.extensions.xr.subspace.Subspace toFramework(
+            @NonNull Subspace subspace) {
+        requireNonNull(subspace);
+
+        return ((SubspaceImpl) subspace).mSubspace;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ActivityPose.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ActivityPose.kt
new file mode 100644
index 0000000..ec1e4e1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ActivityPose.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+
+/**
+ * Interface for a ActivityPose.
+ *
+ * A ActivityPose contains a pose in activity space and it's pose can be transformed into a pose
+ * relative to another ActivityPose.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ActivityPose {
+
+    /**
+     * Returns the activity space pose for this ActivityPose.
+     *
+     * @return Current [Pose] relative to the activity space root.
+     */
+    public fun getActivitySpacePose(): Pose
+
+    /**
+     * Returns a pose relative to this ActivityPose transformed into a pose relative to the
+     * destination.
+     *
+     * @param pose A pose in this ActivityPose's local coordinate space.
+     * @param destination The ActivityPose which the returned pose will be relative to.
+     * @return The pose relative to the destination ActivityPose.
+     */
+    public fun transformPoseTo(pose: Pose, destination: ActivityPose): Pose
+}
+
+/**
+ * The BaseActivityPose is an implementation of ActivityPose interface that wraps a platformAdapter
+ * ActivityPose.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public sealed class BaseActivityPose<out RtActivityPoseType : JxrPlatformAdapter.ActivityPose>(
+    internal val rtActivityPose: RtActivityPoseType
+) : ActivityPose {
+    private companion object {
+        private const val TAG = "BaseRtActivityPose"
+    }
+
+    override fun getActivitySpacePose(): Pose {
+        return rtActivityPose.activitySpacePose
+    }
+
+    override fun transformPoseTo(pose: Pose, destination: ActivityPose): Pose {
+        if (destination !is BaseActivityPose<JxrPlatformAdapter.ActivityPose>) {
+            Log.e(TAG, "Destination must be a subclass of BaseActivityPose!")
+            return Pose.Identity
+        }
+        return rtActivityPose.transformPoseTo(pose, destination.rtActivityPose)
+    }
+}
+
+/** A ActivityPose which tracks a camera's position and view into physical space. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class CameraView
+private constructor(
+    private val rtCameraViewActivityPose: JxrPlatformAdapter.CameraViewActivityPose
+) : BaseActivityPose<JxrPlatformAdapter.CameraViewActivityPose>(rtCameraViewActivityPose) {
+
+    internal companion object {
+        internal fun createLeft(runtime: JxrPlatformAdapter): CameraView? {
+            val cameraViewActivityPose =
+                runtime.getCameraViewActivityPose(
+                    JxrPlatformAdapter.CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE
+                )
+            return cameraViewActivityPose?.let { CameraView(it) }
+        }
+
+        internal fun createRight(runtime: JxrPlatformAdapter): CameraView? {
+            val cameraViewActivityPose =
+                runtime.getCameraViewActivityPose(
+                    JxrPlatformAdapter.CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE
+                )
+            return cameraViewActivityPose?.let { CameraView(it) }
+        }
+    }
+
+    /** Describes the type of camera that this CameraView represents. */
+    public enum class CameraType {
+        /** This CameraView represents an unknown camera view. */
+        UNKNOWN,
+
+        /** This CameraView represents the user's left eye. */
+        LEFT_EYE,
+
+        /** This CameraView represents the user's right eye. */
+        RIGHT_EYE,
+    }
+
+    public val cameraType: CameraType = CameraType.UNKNOWN
+
+    /** Gets the FOV for the camera */
+    public val fov: Fov
+        get() {
+            val rtFov = rtCameraViewActivityPose.fov
+            return Fov(rtFov.angleLeft, rtFov.angleRight, rtFov.angleUp, rtFov.angleDown)
+        }
+}
+
+/**
+ * Head is a ActivityPose used to track the position of the user's head. If there is a left and
+ * right camera it is calculated as the position bettween the two.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Head private constructor(rtActivityPose: JxrPlatformAdapter.HeadActivityPose) :
+    BaseActivityPose<JxrPlatformAdapter.HeadActivityPose>(rtActivityPose) {
+
+    internal companion object {
+
+        /** Factory function for creating [Head] instance. */
+        internal fun create(runtime: JxrPlatformAdapter): Head? {
+            return runtime.headActivityPose?.let { Head(it) }
+        }
+    }
+}
+
+/**
+ * PerceptionSpace is ActivityPose used to track the origin of the space used by ARCore for XR APIs.
+ */
+// TODO: b/360870690 - Remove suppression annotation when API council review is complete.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PerceptionSpace
+private constructor(rtActivityPose: JxrPlatformAdapter.PerceptionSpaceActivityPose) :
+    BaseActivityPose<JxrPlatformAdapter.PerceptionSpaceActivityPose>(rtActivityPose) {
+
+    internal companion object {
+
+        /** Factory function for creating [PerceptionSpace] instance. */
+        internal fun create(runtime: JxrPlatformAdapter): PerceptionSpace =
+            PerceptionSpace(runtime.perceptionSpaceActivityPose)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/AnchorPlacement.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/AnchorPlacement.kt
new file mode 100644
index 0000000..50e5222
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/AnchorPlacement.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Creates an AnchorPlacement for a MovableComponent.
+ *
+ * <p> This will enable the MovableComponent to automatically anchor the attached entity to a new
+ * entity
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AnchorPlacement() {
+    internal val planeTypeFilter: MutableSet<@PlaneTypeValue Int> = HashSet<@PlaneTypeValue Int>()
+    internal val planeSemanticFilter: MutableSet<@PlaneSemanticValue Int> =
+        HashSet<@PlaneSemanticValue Int>()
+
+    public companion object {
+        /**
+         * Creates an anchor placement for anchoring to planes.
+         *
+         * Setting a [PlaneType] or [PlaneSemantic] filter means that the [Entity] with a
+         * [MovableComponent] will be anchored to a plane of that matches at least one of the
+         * specified [PlaneType] filters and at least one specified [PlaneSemantic] filters if it is
+         * released nearby. If no [PlaneType] or no [PlaneSemantic] is set the [Entity] will not be
+         * anchored.
+         *
+         * <p> When an entity is anchored to the plane the pose will be rotated so that it's
+         * Z-vector will be pointing our of the plane (i.e. if it is a panel it will be flat along
+         * the plane. The onMoveEnd callback can be used to listen for the [Entity] being anchored,
+         * reanchored, or unanchored. When anchored the parent will be updated to a new anchor
+         * entity. When unanchored the parent will be set to the [ActivitySpace].
+         *
+         * @param planeTypeFilter The set of plane types to filter by.
+         * @param planeSemanticFilter The set of plane semantics to filter by.
+         */
+        @JvmStatic
+        @JvmOverloads
+        public fun createForPlanes(
+            planeTypeFilter: Set<@PlaneTypeValue Int> = setOf(PlaneType.ANY),
+            planeSemanticFilter: Set<@PlaneSemanticValue Int> = setOf(PlaneSemantic.ANY),
+        ): AnchorPlacement {
+            val placement = AnchorPlacement()
+            placement.planeTypeFilter.addAll(planeTypeFilter)
+            placement.planeSemanticFilter.addAll(planeSemanticFilter)
+            return placement
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Component.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Component.kt
new file mode 100644
index 0000000..2337efa
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Component.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Base interface for all components.
+ *
+ * Components are attached to entities, to add functionality to those entities.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Component {
+    /**
+     * Called when this component is attached to the entity.
+     *
+     * @param entity Entity this component is being attached to.
+     * @return True if the component can attach to given Entity.
+     */
+    public fun onAttach(entity: Entity): Boolean
+
+    /**
+     * Called when this component is detached from the entity.
+     *
+     * @param entity Entity this component is being detached from.
+     */
+    public fun onDetach(entity: Entity)
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Entity.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Entity.kt
new file mode 100644
index 0000000..5affb34
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Entity.kt
@@ -0,0 +1,1142 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("BanConcurrentHashMap", "Deprecation")
+
+package androidx.xr.scenecore
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.Surface
+import android.view.View
+import androidx.annotation.IntDef
+import androidx.annotation.MainThread
+import androidx.annotation.RestrictTo
+import androidx.xr.arcore.Anchor
+import androidx.xr.runtime.Session as PerceptionSession
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity as RtEntity
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity as RtPanelEntity
+import java.time.Duration
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentMap
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicReference
+import java.util.function.Consumer
+
+/**
+ * Interface for a spatial Entity. An Entity's [Pose]s are represented as being relative to their
+ * parent. Applications create and manage Entity instances to construct spatial scenes.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Entity : ActivityPose {
+
+    /**
+     * Sets this Entity to be represented in the parent's coordinate space. From a User's
+     * perspective, as the parent moves, this Entity will move with it. Setting the parent to null
+     * will cause the Entity to not be rendered.
+     *
+     * @param parent The [Entity] to attach to.
+     */
+    public fun setParent(parent: Entity?)
+
+    /**
+     * Returns the parent of this Entity.
+     *
+     * @return The [Entity] that this Entity is attached to. Returns null if this Entity has no
+     *   parent and is the root of its hierarchy.
+     */
+    public fun getParent(): Entity?
+
+    /**
+     * Sets an Entity to be represented in this coordinate space. From a User's perspective, as this
+     * Entity moves, the child Entity will move with it.
+     *
+     * @param child The [Entity] to be attached.
+     */
+    public fun addChild(child: Entity)
+
+    /* TODO b/362296608: Add a getChildren() method. */
+
+    /**
+     * Sets the pose for this Entity, relative to its parent.
+     *
+     * @param pose The [Pose] offset from the parent.
+     */
+    public fun setPose(pose: Pose)
+
+    /**
+     * Returns the pose for this entity, relative to its parent.
+     *
+     * @return Current [Pose] offset from the parent.
+     */
+    public fun getPose(): Pose
+
+    /**
+     * Sets the scale of this entity relative to its parent. This value will affect the rendering of
+     * this Entity's children. As the scale increases, this will uniformly stretch the content of
+     * the Entity.
+     *
+     * @param scale The uniform scale factor from the parent.
+     */
+    public fun setScale(scale: Float)
+
+    /**
+     * Returns the local scale of this entity, not inclusive of the parent's scale.
+     *
+     * @return Current uniform scale applied to self and children.
+     */
+    public fun getScale(): Float
+
+    /**
+     * Returns the accumulated scale of this Entity. This value includes the parent's world space
+     * scale.
+     *
+     * @return Total uniform scale applied to self and children.
+     */
+    public fun getWorldSpaceScale(): Float
+
+    /**
+     * Sets the dimensions in pixels for the Entity.
+     *
+     * @param dimensions Dimensions in pixels. (Z will be ignored)
+     * @deprecated ("This method is deprecated. Use BasePanelEntity<*>.setPixelDimensions()
+     *   instead.")
+     */
+    public fun setSize(dimensions: Dimensions)
+
+    /** Returns the dimensions (in meters) for this Entity. */
+    public fun getSize(): Dimensions
+
+    /**
+     * Sets the alpha transparency of the Entity and its children. Values are in the range [0, 1]
+     * with 0 being fully transparent and 1 being fully opaque.
+     *
+     * This value will affect the rendering of this Entity's children. Children of this node will
+     * have their alpha levels multiplied by this value and any alpha of this entity's ancestors.
+     */
+    public fun setAlpha(alpha: Float)
+
+    /**
+     * Returns the alpha transparency set for this Entity.
+     *
+     * This does not necessarily equal the perceived alpha of the entity as the entity may have some
+     * alpha difference applied from its parent or the system.
+     */
+    public fun getAlpha(): Float
+
+    /**
+     * Returns the global alpha of this entity computed by multiplying the parent's global alpha to
+     * this entity's local alpha.
+     *
+     * This does not necessarily equal the perceived alpha of the entity as the entity may have some
+     * alpha difference applied from the system.
+     *
+     * @return Total [Float] alpha applied to this entity.
+     */
+    public fun getActivitySpaceAlpha(): Float
+
+    /**
+     * Sets the local hidden state of this Entity. When true, this Entity and all descendants will
+     * not be rendered in the scene. When the hidden state is false, an entity will be rendered if
+     * its ancestors are not hidden.
+     *
+     * @param hidden The new local hidden state of this Entity.
+     */
+    public fun setHidden(hidden: Boolean)
+
+    /**
+     * Returns the hidden status of this Entity.
+     *
+     * @param includeParents Whether to include the hidden status of parents in the returned value.
+     * @return If includeParents is true, the returned value will be true if this Entity or any of
+     *   its ancestors is hidden. If includeParents is false, the local hidden state is returned.
+     *   Regardless of the local hidden state, an entity will not be rendered if any of its
+     *   ancestors are hidden.
+     */
+    public fun isHidden(includeParents: Boolean = true): Boolean
+
+    /**
+     * Disposes of any system resources held by this Entity, and transitively calls dispose() on all
+     * its children. Once disposed, this Entity is invalid and cannot be used again.
+     */
+    public fun dispose()
+
+    /**
+     * Sets alternate text for this entity to be consumed by Accessibility systems.
+     *
+     * @param text A11y content.
+     */
+    public fun setContentDescription(text: String)
+
+    /**
+     * Adds a Component to this Entity.
+     *
+     * @param component the Component to be added to the Entity.
+     * @return True if given Component is added to the Entity.
+     */
+    public fun addComponent(component: Component): Boolean
+
+    /**
+     * Removes the given Component from this Entity.
+     *
+     * @param component Component to be removed from this entity.
+     */
+    public fun removeComponent(component: Component)
+
+    /**
+     * Retrieves all Components of the given type [T] and its sub-types attached to this Entity.
+     *
+     * @param type The type of Component to retrieve.
+     * @return List<Component> of the given type attached to this Entity.
+     */
+    public fun <T : Component> getComponentsOfType(type: Class<out T>): List<T>
+
+    /**
+     * Retrieves all components attached to this Entity.
+     *
+     * @return List<Component> attached to this Entity.
+     */
+    public fun getComponents(): List<Component>
+
+    /** Remove all components from this Entity. */
+    public fun removeAllComponents()
+}
+
+/**
+ * ActivitySpace is an Entity used to track the system-managed pose and boundary of the volume
+ * associated with this Spatialized Activity. The Application cannot directly control this volume,
+ * but the system might update it in response to the User moving it or entering or exiting FullSpace
+ * mode.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ActivitySpace
+private constructor(
+    rtActivitySpace: JxrPlatformAdapter.ActivitySpace,
+    entityManager: EntityManager,
+) : BaseEntity<JxrPlatformAdapter.ActivitySpace>(rtActivitySpace, entityManager) {
+
+    internal companion object {
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager
+        ): ActivitySpace = ActivitySpace(adapter.activitySpace, entityManager)
+    }
+
+    private val boundsListeners:
+        ConcurrentMap<
+            Consumer<Dimensions>,
+            JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener
+        > =
+        ConcurrentHashMap()
+
+    /**
+     * The listener registered when using the deprecated registerOnBoundsChangedListener method. We
+     * keep this reference so it can be removed using the corresponding unregister method.
+     */
+    // TODO: b/370538244 - remove with deprecated spatial state callbacks
+    private var registeredBoundsListener: Consumer<Dimensions>? = null
+
+    /**
+     * Retrieves a copy of the current bounds of this ActivitySpace.
+     *
+     * @return [Dimensions] representing the current bounds of this ActivitySpace.
+     */
+    // TODO b/370618648: remove suppression after API review is complete.
+    public fun getBounds(): Dimensions = rtEntity.bounds.toDimensions()
+
+    /**
+     * Adds the given [Consumer] as a listener to be invoked when this ActivitySpace's current
+     * boundary changes. [Consumer#accept(Dimensions)] will be invoked on the main thread.
+     *
+     * @param listener The Consumer to be invoked when this ActivitySpace's current boundary
+     *   changes.
+     */
+    // TODO b/370618648: remove suppression after API review is complete.
+    public fun addBoundsChangedListener(listener: Consumer<Dimensions>): Unit =
+        addBoundsChangedListener(HandlerExecutor.mainThreadExecutor, listener)
+
+    /**
+     * Adds the given [Consumer] as a listener to be invoked when this ActivitySpace's current
+     * boundary changes. [Consumer#accept(Dimensions)] will be invoked on the given executor.
+     *
+     * @param callbackExecutor The executor on which to invoke the listener on.
+     * @param listener The Consumer to be invoked when this ActivitySpace's current boundary
+     *   changes.
+     */
+    // TODO b/370618648: remove suppression after API review is complete.
+    public fun addBoundsChangedListener(
+        callbackExecutor: Executor,
+        listener: Consumer<Dimensions>
+    ) {
+        val rtListener: JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener =
+            JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener { rtDimensions ->
+                callbackExecutor.execute { listener.accept(rtDimensions.toDimensions()) }
+            }
+        boundsListeners.compute(
+            listener,
+            { _, _ ->
+                rtEntity.addOnBoundsChangedListener(rtListener)
+                rtListener
+            },
+        )
+    }
+
+    /**
+     * Releases the given [Consumer] from receiving updates when the ActivitySpace's boundary
+     * changes.
+     *
+     * @param listener The Consumer to be removed from receiving updates.
+     */
+    // TODO b/370618648: remove suppression after API review is complete.
+    public fun removeBoundsChangedListener(listener: Consumer<Dimensions>): Unit {
+        boundsListeners.computeIfPresent(
+            listener,
+            { _, rtListener ->
+                rtEntity.removeOnBoundsChangedListener(rtListener)
+                null // returning null from computeIfPresent removes this entry from the Map
+            },
+        )
+    }
+
+    /**
+     * Sets a callback to be invoked when the bounds of the ActivitySpace change. The callback will
+     * be dispatched on the UI thread.
+     *
+     * @param listener A ((Dimensions) -> Unit) callback, where Dimensions are in meters.
+     */
+    // TODO: b/370538244 - remove with deprecated spatial state callbacks
+    @Deprecated(message = "use addBoundsChangedListener(Consumer<Dimensions>)")
+    public fun registerOnBoundsChangedListener(listener: OnBoundsChangeListener) {
+        if (registeredBoundsListener != null) unregisterOnBoundsChangedListener()
+        registeredBoundsListener =
+            Consumer<Dimensions> { bounds -> listener.onBoundsChanged(bounds) }
+        addBoundsChangedListener(registeredBoundsListener!!)
+    }
+
+    /** Clears the listener set by [registerOnBoundsChangedListener]. */
+    // TODO: b/370538244 - remove with deprecated spatial state callbacks
+    @Deprecated(message = "use removeBoundsChangedListener(Consumer<Dimensions>)")
+    public fun unregisterOnBoundsChangedListener() {
+        if (registeredBoundsListener != null) {
+            removeBoundsChangedListener(registeredBoundsListener!!)
+            registeredBoundsListener = null
+        }
+    }
+
+    /**
+     * Registers a listener to be called when the underlying space has moved or changed.
+     *
+     * @param listener The listener to register if non-null, else stops listening if null.
+     * @param executor The executor to run the listener on. Defaults to SceneCore executor if null.
+     */
+    @JvmOverloads
+    @Suppress("ExecutorRegistration")
+    public fun setOnSpaceUpdatedListener(
+        listener: OnSpaceUpdatedListener?,
+        executor: Executor? = null,
+    ) {
+        rtEntity.setOnSpaceUpdatedListener(listener?.let { { it.onSpaceUpdated() } }, executor)
+    }
+}
+
+/** The BaseEntity is an implementation of Entity interface that wraps a platform entity. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public sealed class BaseEntity<out RtEntityType : RtEntity>(
+    internal val rtEntity: RtEntityType,
+    internal val entityManager: EntityManager,
+) : Entity, BaseActivityPose<JxrPlatformAdapter.ActivityPose>(rtEntity) {
+
+    init {
+        entityManager.setEntityForRtEntity(rtEntity, this)
+    }
+
+    private companion object {
+        private const val TAG = "BaseEntity"
+    }
+
+    private var dimensions: Dimensions = Dimensions()
+    private val componentList = mutableListOf<Component>()
+
+    override fun setParent(parent: Entity?) {
+        if (parent == null) {
+            rtEntity.parent = null
+            return
+        }
+
+        if (parent !is BaseEntity<RtEntity>) {
+            Log.e(TAG, "Parent must be a subclass of BaseEntity")
+            return
+        }
+        rtEntity.parent = parent.rtEntity
+    }
+
+    override fun getParent(): Entity? {
+        return rtEntity.parent?.let { entityManager.getEntityForRtEntity(it) }
+    }
+
+    override fun addChild(child: Entity) {
+        if (child !is BaseEntity<RtEntity>) {
+            Log.e(TAG, "Child must be a subclass of BaseEntity!")
+            return
+        }
+        rtEntity.addChild(child.rtEntity)
+    }
+
+    override fun setPose(pose: Pose) {
+        rtEntity.setPose(pose)
+    }
+
+    override fun getPose(): Pose {
+        return rtEntity.pose
+    }
+
+    override fun setScale(scale: Float) {
+        rtEntity.setScale(Vector3(scale, scale, scale))
+    }
+
+    override fun getScale(): Float {
+        return rtEntity.scale.x
+    }
+
+    override fun getWorldSpaceScale(): Float {
+        return rtEntity.worldSpaceScale.x
+    }
+
+    override fun setSize(dimensions: Dimensions) {
+        rtEntity.setSize(dimensions.toRtDimensions())
+        this.dimensions = dimensions
+    }
+
+    // TODO: b/328620113 - Get the dimensions from EntityImpl.
+    override fun getSize(): Dimensions = dimensions
+
+    override fun setAlpha(alpha: Float) {
+        rtEntity.alpha = alpha
+    }
+
+    override fun getAlpha(): Float = rtEntity.alpha
+
+    override fun getActivitySpaceAlpha(): Float = rtEntity.activitySpaceAlpha
+
+    override fun setHidden(hidden: Boolean): Unit = rtEntity.setHidden(hidden)
+
+    override fun isHidden(includeParents: Boolean): Boolean = rtEntity.isHidden(includeParents)
+
+    override fun dispose() {
+        removeAllComponents()
+        entityManager.removeEntity(this)
+        rtEntity.dispose()
+    }
+
+    override fun addComponent(component: Component): Boolean {
+        if (component.onAttach(this)) {
+            componentList.add(component)
+            return true
+        }
+        return false
+    }
+
+    override fun removeComponent(component: Component) {
+        if (componentList.contains(component)) {
+            component.onDetach(this)
+            componentList.remove(component)
+        }
+    }
+
+    override fun <T : Component> getComponentsOfType(type: Class<out T>): List<T> {
+        return componentList.filterIsInstance(type)
+    }
+
+    override fun getComponents(): List<Component> {
+        return componentList
+    }
+
+    override fun removeAllComponents() {
+        componentList.forEach { it.onDetach(this) }
+        componentList.clear()
+    }
+
+    override fun setContentDescription(text: String) {}
+}
+
+/**
+ * An Entity that itself has no content. ContentlessEntity is useful for organizing the placement,
+ * movement of a group of SceneCore Entities.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ContentlessEntity
+private constructor(rtEntity: RtEntity, entityManager: EntityManager) :
+    BaseEntity<RtEntity>(rtEntity, entityManager) {
+    internal companion object {
+        /** Factory method to create ContentlessEntity entities. */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            name: String,
+            pose: Pose = Pose.Identity,
+        ): Entity =
+            ContentlessEntity(
+                adapter.createEntity(pose, name, adapter.activitySpaceRootImpl),
+                entityManager,
+            )
+    }
+}
+
+/** Provides implementations for common Panel functionality. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public sealed class BasePanelEntity<out RtPanelEntityType : RtPanelEntity>(
+    private val rtPanelEntity: RtPanelEntityType,
+    entityManager: EntityManager,
+) : BaseEntity<RtPanelEntity>(rtPanelEntity, entityManager) {
+
+    /**
+     * Sets the corner radius of the PanelEntity.
+     *
+     * @param radius The radius of the corners, in meters.
+     * @throws IllegalArgumentException if radius is <= 0.0f.
+     */
+    public fun setCornerRadius(radius: Float) {
+        rtPanelEntity.setCornerRadius(radius)
+    }
+
+    /** Gets the corner radius of this PanelEntity in meters. Has a default value of 0. */
+    public fun getCornerRadius(): Float {
+        return rtPanelEntity.cornerRadius
+    }
+
+    /**
+     * Returns the dimensions of the view underlying this PanelEntity.
+     *
+     * @return The current (width, height) of the underlying surface in pixels.
+     */
+    public fun getPixelDimensions(): PixelDimensions {
+        return rtPanelEntity.getPixelDimensions().toPixelDimensions()
+    }
+
+    /**
+     * Sets the pixel (not Dp) dimensions of the view underlying this PanelEntity. Calling this
+     * might cause the layout of the Panel contents to change. Updating this will not cause the
+     * scale or pixel density to change.
+     *
+     * @param pxDimensions The [PixelDimensions] of the underlying surface to set.
+     */
+    public fun setPixelDimensions(pxDimensions: PixelDimensions) {
+        rtPanelEntity.setPixelDimensions(pxDimensions.toRtPixelDimensions())
+    }
+
+    /**
+     * Gets the number of pixels per meter for this panel. This value reflects changes to scale,
+     * including parent scale.
+     *
+     * @return Vector3 scale applied to pixels within the Panel. (Z will be 0)
+     */
+    public fun getPixelDensity(): Vector3 {
+        return rtPanelEntity.pixelDensity
+    }
+
+    /**
+     * Returns the spatial size of this Panel in meters. This includes any scaling applied to this
+     * panel by itself or its parents, which might be set via changes to setScale.
+     *
+     * @return [Dimensions] size of this panel in meters. (Z will be 0)
+     */
+    override fun getSize(): Dimensions {
+        return rtPanelEntity.getSize().toDimensions()
+    }
+}
+
+/**
+ * GltfModelEntity is a concrete implementation of Entity that hosts a glTF model.
+ *
+ * Note: The size property of this Entity is always reported as {0, 0, 0}, regardless of the actual
+ * size of the model.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class GltfModelEntity
+private constructor(rtEntity: JxrPlatformAdapter.GltfEntity, entityManager: EntityManager) :
+    BaseEntity<JxrPlatformAdapter.GltfEntity>(rtEntity, entityManager) {
+    // TODO: b/362368652 - Add an OnAnimationEvent() Listener interface
+
+    /** Specifies the current animation state of the GltfModelEntity. */
+    @IntDef(AnimationState.PLAYING, AnimationState.STOPPED)
+    @Retention(AnnotationRetention.SOURCE)
+    internal annotation class AnimationStateValue
+
+    public object AnimationState {
+        public const val PLAYING: Int = 0
+        public const val STOPPED: Int = 1
+    }
+
+    internal companion object {
+        /**
+         * Factory method for GltfModelEntity.
+         *
+         * @param adapter Jetpack XR platform adapter.
+         * @param model [GltfModel] which this entity will display.
+         * @param pose Pose for this [GltfModelEntity], relative to its parent.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            model: GltfModel,
+            pose: Pose = Pose.Identity,
+        ): GltfModelEntity =
+            GltfModelEntity(
+                adapter.createGltfEntity(pose, model.model, adapter.activitySpaceRootImpl),
+                entityManager,
+            )
+    }
+
+    /** Returns the current animation state of this glTF entity. */
+    @AnimationStateValue
+    public fun getAnimationState(): Int {
+        return when (rtEntity.animationState) {
+            JxrPlatformAdapter.GltfEntity.AnimationState.PLAYING -> return AnimationState.PLAYING
+            JxrPlatformAdapter.GltfEntity.AnimationState.STOPPED -> return AnimationState.STOPPED
+            else -> AnimationState.STOPPED
+        }
+    }
+
+    /**
+     * Starts the animation with the given name.
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     *
+     * @param animationName The name of the animation to start. If null, the first animation found
+     *   in the glTF will be played.
+     * @param loop Whether the animation should loop.
+     */
+    @MainThread
+    @JvmOverloads
+    public fun startAnimation(loop: Boolean, animationName: String? = null) {
+        rtEntity.startAnimation(loop, animationName)
+    }
+
+    /**
+     * Stops the animation of the glTF entity.
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     */
+    @MainThread
+    public fun stopAnimation() {
+        rtEntity.stopAnimation()
+    }
+}
+
+/** StereoSurfaceEntity is a concrete implementation of Entity that hosts a StereoSurface Panel. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class StereoSurfaceEntity
+private constructor(
+    rtEntity: JxrPlatformAdapter.StereoSurfaceEntity,
+    entityManager: EntityManager,
+) : BaseEntity<JxrPlatformAdapter.StereoSurfaceEntity>(rtEntity, entityManager) {
+
+    /**
+     * Specifies how the surface content will be routed for stereo viewing. Applications must render
+     * into the surface in accordance with what is specified here in order for the compositor to
+     * correctly produce a stereoscopic view to the user.
+     *
+     * Values here match values from androidx.media3.common.C.StereoMode in
+     * //third_party/java/android_libs/media:common
+     */
+    @IntDef(StereoMode.MONO, StereoMode.TOP_BOTTOM, StereoMode.SIDE_BY_SIDE)
+    @Retention(AnnotationRetention.SOURCE)
+    internal annotation class StereoModeValue
+
+    public object StereoMode {
+        // Each eye will see the entire surface (no separation)
+        public const val MONO: Int = 0
+        // The [bottom, top] halves of the surface will map to [left, right] eyes
+        public const val TOP_BOTTOM: Int = 1
+        // The [left, right] halves of the surface will map to [left, right] eyes
+        public const val SIDE_BY_SIDE: Int = 2
+    }
+
+    internal companion object {
+        private fun getRtStereoMode(stereoMode: Int): Int {
+            return when (stereoMode) {
+                StereoMode.MONO -> JxrPlatformAdapter.StereoSurfaceEntity.StereoMode.MONO
+                StereoMode.TOP_BOTTOM ->
+                    JxrPlatformAdapter.StereoSurfaceEntity.StereoMode.TOP_BOTTOM
+                else -> JxrPlatformAdapter.StereoSurfaceEntity.StereoMode.SIDE_BY_SIDE
+            }
+        }
+
+        /**
+         * Factory method for StereoSurfaceEntity.
+         *
+         * @param adapter JxrPlatformAdapter to use.
+         * @param entityManager A SceneCore EntityManager
+         * @param stereoMode An [Int] which defines how surface subregions map to eyes
+         * @param dimensions A [Dimensions] which specifies the size of the canvas relative to
+         *   parent
+         * @param pose Pose for this StereoSurface entity, relative to its parent.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            stereoMode: Int = StereoMode.SIDE_BY_SIDE,
+            dimensions: Dimensions = Dimensions(1.0f, 1.0f, 1.0f),
+            pose: Pose = Pose.Identity,
+        ): StereoSurfaceEntity =
+            StereoSurfaceEntity(
+                adapter.createStereoSurfaceEntity(
+                    getRtStereoMode(stereoMode),
+                    dimensions.toRtDimensions(),
+                    pose,
+                    adapter.activitySpaceRootImpl,
+                ),
+                entityManager,
+            )
+    }
+
+    /**
+     * Changes the width and height of the "spatial canvas" which the surface is mapped to. (0,0,0)
+     * for this Entity is the canvas's center. (In unscaled ActivitySpace, this is 1 unit per meter)
+     * Width and height <= 0 are unsupported (Depth is ignored).
+     */
+    public var dimensions: Dimensions
+        get() = rtEntity.dimensions.toDimensions()
+        set(value) {
+            rtEntity.dimensions = value.toRtDimensions()
+        }
+
+    /**
+     * Returns a surface into which the application can render stereo image content.
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     */
+    @MainThread
+    public fun getSurface(): Surface {
+        return rtEntity.surface
+    }
+}
+
+/** PanelEntity creates a spatial panel in Android XR. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public open class PanelEntity
+internal constructor(
+    rtEntity: JxrPlatformAdapter.PanelEntity,
+    entityManager: EntityManager,
+    // TODO(ricknels): move isMainPanelEntity check to JxrPlatformAdapter.
+    public val isMainPanelEntity: Boolean = false,
+) : BasePanelEntity<JxrPlatformAdapter.PanelEntity>(rtEntity, entityManager) {
+
+    internal companion object {
+        /**
+         * Factory method for PanelEntity.
+         *
+         * @param adapter JxrPlatformAdapter to use.
+         * @param view View to insert in this panel.
+         * @param surfaceDimensionsPx Dimensions for the underlying surface for the given view.
+         * @param dimensions The size of this spatial Panel Entity in Meters.
+         * @param name Name of this panel.
+         * @param context Activity which created this panel.
+         * @param pose Pose for this panel, relative to its parent.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            view: View,
+            surfaceDimensionsPx: Dimensions,
+            dimensions: Dimensions,
+            name: String,
+            context: Context,
+            pose: Pose = Pose.Identity,
+        ): PanelEntity =
+            PanelEntity(
+                adapter.createPanelEntity(
+                    pose,
+                    view,
+                    PixelDimensions(
+                            surfaceDimensionsPx.width.toInt(),
+                            surfaceDimensionsPx.height.toInt()
+                        )
+                        .toRtPixelDimensions(),
+                    dimensions.toRtDimensions(),
+                    name,
+                    context,
+                    adapter.activitySpaceRootImpl,
+                ),
+                entityManager,
+            )
+
+        /** Returns the PanelEntity backed by the main window for the Activity. */
+        internal fun createMainPanelEntity(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+        ): PanelEntity =
+            PanelEntity(adapter.mainPanelEntity, entityManager, isMainPanelEntity = true)
+    }
+}
+
+/**
+ * ActivityPanelEntity creates a spatial panel for embedding an Activity in Android XR. Users can
+ * either use an intent to launch an activity in the given panel or provide an instance of activity
+ * to move into this panel. Calling dispose() on this entity will destroy the underlying activity.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ActivityPanelEntity
+private constructor(
+    private val rtActivityPanelEntity: JxrPlatformAdapter.ActivityPanelEntity,
+    entityManager: EntityManager,
+) : PanelEntity(rtActivityPanelEntity, entityManager) {
+
+    /**
+     * Launches an activity in the given panel. Subsequent calls to this method will replace the
+     * already existing activity in the panel with the new one. If the intent fails to launch the
+     * activity, the panel will not be visible. Note this will not update the dimensions of the
+     * surface underlying the panel. The Activity will be letterboxed as required to fit the size of
+     * the panel. The underlying surface can be resized by calling setPixelDimensions().
+     *
+     * @param intent Intent to launch the activity.
+     * @param bundle Bundle to pass to the activity, can be null.
+     */
+    @JvmOverloads
+    public fun launchActivity(intent: Intent, bundle: Bundle? = null) {
+        rtActivityPanelEntity.launchActivity(intent, bundle)
+    }
+
+    /**
+     * Moves the given activity into this panel. Note this will not update the dimensions of the
+     * surface underlying the panel. The Activity will be letterboxed as required to fit the size of
+     * the panel. The underlying surface can be resized by calling setPixelDimensions().
+     *
+     * @param activity Activity to move into this panel.
+     */
+    public fun moveActivity(activity: Activity) {
+        rtActivityPanelEntity.moveActivity(activity)
+    }
+
+    internal companion object {
+        /**
+         * Factory method for ActivityPanelEntity.
+         *
+         * @param adapter JxrPlatformAdapter to use.
+         * @param windowBoundsPx Bounds for the underlying surface for the given view.
+         * @param name Name of this panel.
+         * @param hostActivity Activity which created this panel.
+         * @param pose Pose for this panel, relative to its parent.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            windowBoundsPx: PixelDimensions,
+            name: String,
+            hostActivity: Activity,
+            pose: Pose = Pose.Identity,
+        ): ActivityPanelEntity =
+            ActivityPanelEntity(
+                adapter.createActivityPanelEntity(
+                    pose,
+                    windowBoundsPx.toRtPixelDimensions(),
+                    name,
+                    hostActivity,
+                    adapter.activitySpaceRootImpl,
+                ),
+                entityManager,
+            )
+    }
+}
+
+/** TODO - Convert AnchorEntity into a Space (This will remove setParent) */
+
+/**
+ * An AnchorEntity is created to track a Pose relative to some position or surface in the "Real
+ * World." Children of this Entity will remain positioned relative to that location in the real
+ * world, for the purposes of creating Augmented Reality experiences.
+ *
+ * Note that Anchors are only relative to the "real world", and not virtual environments. Also,
+ * calling setParent() on an AnchorEntity has no effect, as the parenting of an Anchor is controlled
+ * by the system.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AnchorEntity
+private constructor(rtEntity: JxrPlatformAdapter.AnchorEntity, entityManager: EntityManager) :
+    BaseEntity<JxrPlatformAdapter.AnchorEntity>(rtEntity, entityManager) {
+    private val state = AtomicReference(rtEntity.state.fromRtState())
+    private val persistState = AtomicReference(rtEntity.persistState.fromRtPersistState())
+
+    private var onStateChangedListener: OnStateChangedListener? = null
+
+    /** Specifies the current tracking state of the Anchor. */
+    @Target(AnnotationTarget.TYPE)
+    @IntDef(State.ANCHORED, State.UNANCHORED, State.TIMEDOUT, State.ERROR)
+    @Retention(AnnotationRetention.SOURCE)
+    internal annotation class StateValue
+
+    public object State {
+        /**
+         * The ANCHORED state means that this Anchor is being actively tracked and updated by the
+         * perception stack. The application should expect children to maintain their relative
+         * positioning to the system's best understanding of a pose in the real world.
+         */
+        public const val ANCHORED: Int = 0
+        /**
+         * An UNANCHORED state could mean that the perception stack hasn't found an anchor for this
+         * Space, that it has lost tracking.
+         */
+        public const val UNANCHORED: Int = 1
+        /**
+         * The AnchorEntity timed out while searching for an underlying anchor. This it is not
+         * possible to recover the AnchorEntity.
+         */
+        public const val TIMEDOUT: Int = 2
+        /**
+         * The ERROR state means that something has gone wrong and this AnchorEntity is invalid
+         * without the possibility of recovery.
+         */
+        public const val ERROR: Int = 3
+    }
+
+    /** Specifies the current persist state of the Anchor. */
+    public enum class PersistState {
+        /** The anchor hasn't been requested to persist. */
+        PERSIST_NOT_REQUESTED,
+        /** The anchor is requested to persist but hasn't been persisted yet. */
+        PERSIST_PENDING,
+        /** The anchor is persisted successfully. */
+        PERSISTED,
+    }
+
+    /** Returns the current tracking state for this AnchorEntity. */
+    public fun getState(): @StateValue Int = state.get()
+
+    /** Registers a listener callback to be issued when an anchor's state changes. */
+    @Suppress("ExecutorRegistration")
+    public fun setOnStateChangedListener(onStateChangedListener: OnStateChangedListener?) {
+        this.onStateChangedListener = onStateChangedListener
+    }
+
+    /** Updates the current state. */
+    private fun setState(newState: @StateValue Int) {
+        state.set(newState)
+        onStateChangedListener?.onStateChanged(newState)
+    }
+
+    /** Gets the current PersistState. */
+    public fun getPersistState(): PersistState = persistState.get()
+
+    /**
+     * Requests to persist the anchor. If the anchor's State is not ANCHORED, no request will be
+     * sent and null is returned. If the request is sent successfully, returns an UUID of the anchor
+     * immediately; otherwise returns null. After this call, client should use getPersistState() to
+     * check the PersistState of the anchor. If the anchor's PersistState becomes PERSISTED before
+     * the app is closed the anchor can be recreated in a new session by calling
+     * Session.createPersistedAnchorEntity(uuid). If the PersistState doesn't become PERSISTED
+     * before the app is closed, the recreation will fail.
+     */
+    public fun persist(): UUID? {
+        if (state.get() != State.ANCHORED) {
+            Log.e(TAG, "Cannot persist an anchor that is not in the ANCHORED state.")
+            return null
+        }
+        val uuid = rtEntity.persist()
+        if (uuid == null) {
+            Log.e(TAG, "Failed to get a UUID for the anchor.")
+            return null
+        }
+
+        rtEntity.registerPersistStateChangeListener { newRtPersistState ->
+            persistState.set(newRtPersistState.fromRtPersistState())
+        }
+        return uuid
+    }
+
+    /**
+     * Loads the ARCore for XR Anchor using a Jetpack XR Runtime session.
+     *
+     * @param session the Jetpack XR Runtime session to load the Anchor from.
+     * @return the ARCore for XR Anchor corresponding to the native pointer.
+     */
+    // TODO(b/373711152) : Remove this method once the ARCore for XR API migration is done.
+    public fun getAnchor(session: PerceptionSession): Anchor {
+        return Anchor.loadFromNativePointer(session, rtEntity.nativePointer())
+    }
+
+    internal companion object {
+        private const val TAG = "AnchorEntity"
+
+        /**
+         * Factory method for AnchorEntity.
+         *
+         * @param adapter JxrPlatformAdapter to use.
+         * @param bounds Bounds for this Anchor Entity.
+         * @param planeType Orientation for the plane to which this Anchor should attach.
+         * @param planeSemantic Semantics for the plane to which this Anchor should attach.
+         * @param timeout Maximum time to search for the anchor, if a suitable plane is not found
+         *   within the timeout time the AnchorEntity state will be set to TIMED_OUT.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            bounds: Dimensions,
+            planeType: @PlaneTypeValue Int,
+            planeSemantic: @PlaneSemanticValue Int,
+            timeout: Duration = Duration.ZERO,
+        ): AnchorEntity {
+            val rtAnchorEntity =
+                adapter.createAnchorEntity(
+                    bounds.toRtDimensions(),
+                    planeType.toRtPlaneType(),
+                    planeSemantic.toRtPlaneSemantic(),
+                    timeout,
+                )
+            return create(rtAnchorEntity, entityManager)
+        }
+
+        /**
+         * Factory method for AnchorEntity.
+         *
+         * @param anchor Anchor to create an AnchorEntity for.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            anchor: Anchor,
+        ): AnchorEntity {
+            val rtAnchorEntity = adapter.createAnchorEntity(anchor)
+            return create(rtAnchorEntity, entityManager)
+        }
+
+        /**
+         * Factory method for AnchorEntity.
+         *
+         * @param rtAnchorEntity Runtime AnchorEntity instance.
+         */
+        internal fun create(
+            rtAnchorEntity: JxrPlatformAdapter.AnchorEntity,
+            entityManager: EntityManager,
+        ): AnchorEntity {
+            val anchorEntity = AnchorEntity(rtAnchorEntity, entityManager)
+            rtAnchorEntity.setOnStateChangedListener { newRtState ->
+                when (newRtState) {
+                    JxrPlatformAdapter.AnchorEntity.State.UNANCHORED ->
+                        anchorEntity.setState(State.UNANCHORED)
+                    JxrPlatformAdapter.AnchorEntity.State.ANCHORED ->
+                        anchorEntity.setState(State.ANCHORED)
+                    JxrPlatformAdapter.AnchorEntity.State.TIMED_OUT ->
+                        anchorEntity.setState(State.TIMEDOUT)
+                    JxrPlatformAdapter.AnchorEntity.State.ERROR ->
+                        anchorEntity.setState(State.ERROR)
+                }
+            }
+            return anchorEntity
+        }
+
+        /**
+         * Factory method for AnchorEntity.
+         *
+         * @param adapter JxrPlatformAdapter to use.
+         * @param uuid UUID of the persisted Anchor Entity to create.
+         * @param timeout Maximum time to search for the anchor, if a persisted anchor isn't located
+         *   within the timeout time the AnchorEntity state will be set to TIMED_OUT.
+         */
+        internal fun create(
+            adapter: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            uuid: UUID,
+            timeout: Duration = Duration.ZERO,
+        ): AnchorEntity {
+            val rtAnchorEntity = adapter.createPersistedAnchorEntity(uuid, timeout)
+            val anchorEntity = AnchorEntity(rtAnchorEntity, entityManager)
+            rtAnchorEntity.setOnStateChangedListener { newRtState ->
+                when (newRtState) {
+                    JxrPlatformAdapter.AnchorEntity.State.UNANCHORED ->
+                        anchorEntity.setState(State.UNANCHORED)
+                    JxrPlatformAdapter.AnchorEntity.State.ANCHORED ->
+                        anchorEntity.setState(State.ANCHORED)
+                    JxrPlatformAdapter.AnchorEntity.State.TIMED_OUT ->
+                        anchorEntity.setState(State.TIMEDOUT)
+                    JxrPlatformAdapter.AnchorEntity.State.ERROR ->
+                        anchorEntity.setState(State.ERROR)
+                }
+            }
+            return anchorEntity
+        }
+    }
+
+    /**
+     * Extension function that converts [JxrPlatformAdapter.AnchorEntity.State] to
+     * [AnchorEntity.State].
+     */
+    private fun JxrPlatformAdapter.AnchorEntity.State.fromRtState() =
+        when (this) {
+            JxrPlatformAdapter.AnchorEntity.State.UNANCHORED -> State.UNANCHORED
+            JxrPlatformAdapter.AnchorEntity.State.ANCHORED -> State.ANCHORED
+            JxrPlatformAdapter.AnchorEntity.State.TIMED_OUT -> State.TIMEDOUT
+            JxrPlatformAdapter.AnchorEntity.State.ERROR -> State.ERROR
+        }
+
+    /**
+     * Extension function that converts [JxrPlatformAdapter.AnchorEntity.PersistState] to
+     * [AnchorEntity.PersistState].
+     */
+    private fun JxrPlatformAdapter.AnchorEntity.PersistState.fromRtPersistState() =
+        when (this) {
+            JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_NOT_REQUESTED ->
+                PersistState.PERSIST_NOT_REQUESTED
+            JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_PENDING ->
+                PersistState.PERSIST_PENDING
+            JxrPlatformAdapter.AnchorEntity.PersistState.PERSISTED -> PersistState.PERSISTED
+        }
+
+    /**
+     * Registers a listener to be called when the Anchor moves relative to its underlying space.
+     *
+     * <p> The callback is triggered by any anchor movements such as those made by the underlying
+     * perception stack to maintain the anchor's position relative to the real world. Any cached
+     * data relative to the activity space or any other "space" should be updated when this callback
+     * is triggered.
+     *
+     * @param listener The listener to register if non-null, else stops listening if null.
+     * @param executor The executor to run the listener on. Defaults to SceneCore executor if null.
+     */
+    @JvmOverloads
+    @Suppress("ExecutorRegistration")
+    public fun setOnSpaceUpdatedListener(
+        listener: OnSpaceUpdatedListener?,
+        executor: Executor? = null,
+    ) {
+        rtEntity.setOnSpaceUpdatedListener(listener?.let { { it.onSpaceUpdated() } }, executor)
+    }
+}
+
+// TODO: b/370538244 - remove with deprecated spatial state callbacks
+@Deprecated(message = "Use addBoundsChangedListener(Consumer<Dimensions>)")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface OnBoundsChangeListener {
+    public fun onBoundsChanged(bounds: Dimensions) // Dimensions are in meters.
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface OnStateChangedListener {
+    public fun onStateChanged(newState: @AnchorEntity.StateValue Int)
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface OnSpaceUpdatedListener {
+    public fun onSpaceUpdated()
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/EntityManager.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/EntityManager.kt
new file mode 100644
index 0000000..4fe4764
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/EntityManager.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("BanConcurrentHashMap")
+
+package androidx.xr.scenecore
+
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity as RuntimeEntity
+import java.util.concurrent.ConcurrentHashMap
+
+/** Manages the mapping between [RuntimeEntity] and [Entity] for a given SceneCore [Session]. */
+internal class EntityManager {
+    private val rtEntityEntityMap = ConcurrentHashMap<RuntimeEntity, Entity>()
+
+    /**
+     * Returns the [Entity] associated with the given [RuntimeEntity].
+     *
+     * @param rtEntity the [RuntimeEntity] to get the associated [Entity] for.
+     * @return [java.util.Optional] containing the [Entity] associated with the given
+     *   [RuntimeEntity], or empty if no such [Entity] exists.
+     */
+    internal fun getEntityForRtEntity(rtEntity: RuntimeEntity): Entity? =
+        rtEntityEntityMap[rtEntity]
+
+    /**
+     * Sets the [Entity] associated with the given [RuntimeEntity].
+     *
+     * @param rtEntity the [RuntimeEntity] to set the associated [Entity] for.
+     * @param entity the [Entity] to associate with the given [RuntimeEntity].
+     */
+    internal fun setEntityForRtEntity(rtEntity: RuntimeEntity, entity: Entity) {
+        rtEntityEntityMap[rtEntity] = entity
+    }
+
+    /**
+     * Inline function to get all entities of a given type.
+     *
+     * @param T the type of [Entity] to return.
+     * @return a list of all [Entity]s of type [T] (including subtypes of [T]).
+     */
+    internal inline fun <reified T : Entity> getEntities(): List<T> {
+        return rtEntityEntityMap.values.filterIsInstance<T>()
+    }
+
+    /**
+     * Returns all [Entity]s of the given type or its subtypes.
+     *
+     * @param type the type of [Entity] to return.
+     * @return a list of all [Entity]s of the given type or its subtypes.
+     */
+    internal fun <T : Entity> getEntitiesOfType(type: Class<out T>): List<T> =
+        rtEntityEntityMap.values.filterIsInstance(type)
+
+    /**
+     * Returns a collection of all [Entity]s.
+     *
+     * @return a collection of all [Entity]s.
+     */
+    internal fun getAllEntities(): Collection<Entity> {
+        return rtEntityEntityMap.values
+    }
+
+    /**
+     * Removes the given [Entity] from the map.
+     *
+     * @param entity the [Entity] to remove from the map.
+     */
+    internal fun removeEntity(entity: Entity) {
+        rtEntityEntityMap.remove((entity as BaseEntity<*>).rtEntity)
+    }
+
+    /**
+     * Removes the given [JxrPlatformAdapter.Entity] from the map.
+     *
+     * @param entity the [JxrPlatformAdapter.Entity] to remove from the map.
+     */
+    internal fun removeEntity(entity: JxrPlatformAdapter.Entity) {
+        rtEntityEntityMap.remove(entity)
+    }
+
+    /** Clears the EntityManager. */
+    internal fun clear() {
+        rtEntityEntityMap.clear()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ExrImage.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ExrImage.kt
new file mode 100644
index 0000000..6441e0c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ExrImage.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+import androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource as RtExrImage
+
+/** Interface for image formats in SceneCore. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Image
+
+/**
+ * ExrImage represents an EXR Image resource in SceneCore. EXR images are used by the [Environment]
+ * for drawing skyboxes.
+ */
+// TODO(b/319269278): Make this and GltfModel derive from a common Resource base class which has
+//                    async helpers.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ExrImage internal constructor(public val image: RtExrImage) : Image {
+
+    internal companion object {
+        internal fun create(runtime: JxrPlatformAdapter, name: String): ExrImage {
+            val exrImageFuture = runtime.loadExrImageByAssetName(name)
+            // TODO: b/323022003 - Implement async loading of [ExrImage].
+            return ExrImage(exrImageFuture!!.get())
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as ExrImage
+
+        // Perform a structural equality check on the underlying image.
+        if (image != other.image) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        return image.hashCode()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InputEvent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InputEvent.kt
new file mode 100644
index 0000000..1c9e477
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InputEvent.kt
@@ -0,0 +1,290 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Vector3
+import kotlin.annotation.Retention
+
+/** Listener for [InputEvent]s. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun interface InputEventListener {
+    public fun onInputEvent(inputEvent: InputEvent)
+}
+
+/**
+ * Defines input events for XRCore.
+ *
+ * @param source Type of device that generated this event.
+ * @param pointerType Type of the individual pointer.
+ * @param timestamp Timestamp from android.os.SystemClock#uptimeMillis time base.
+ * @param origin The origin of the ray in the receiver's activity space.
+ * @param direction A point indicating the direction the ray is pointing, in the receiver's activity
+ *   space.
+ * @param action Actions similar to Android's
+ *   [MotionEvent](https://developer.android.com/reference/android/view/MotionEvent") for keeping
+ *   track of a sequence of events on the same target, e.g., * HOVER_ENTER -> HOVER_MOVE ->
+ *   HOVER_EXIT * DOWN -> MOVE -> UP
+ * @param hitInfo Info about the first scene entity (closest to the ray origin) that was hit by the
+ *   input ray, if any. This will be null if no entity was hit. Note that the hit entity remains the
+ *   same during an ongoing DOWN -> MOVE -> UP action, even if the pointer stops hitting the entity
+ *   during the action.
+ * @param secondaryHitInfo Info about the second scene entity from the same task that was hit, if
+ *   any.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class InputEvent(
+    @Source public val source: Int,
+    @PointerType public val pointerType: Int,
+    public val timestamp: Long,
+    public val origin: Vector3,
+    public val direction: Vector3,
+    @Action public val action: Int,
+    public val hitInfo: HitInfo? = null,
+    public val secondaryHitInfo: HitInfo? = null,
+) {
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is InputEvent) return false
+
+        if (source != other.source) return false
+        if (pointerType != other.pointerType) return false
+        if (timestamp != other.timestamp) return false
+        if (origin != other.origin) return false
+        if (direction != other.direction) return false
+        if (action != other.action) return false
+        if (hitInfo != other.hitInfo) return false
+        if (secondaryHitInfo != other.secondaryHitInfo) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = source.hashCode()
+        result = 31 * result + pointerType.hashCode()
+        result = 31 * result + timestamp.hashCode()
+        result = 31 * result + origin.hashCode()
+        result = 31 * result + direction.hashCode()
+        result = 31 * result + action.hashCode()
+        result = 31 * result + (hitInfo?.hashCode() ?: 0)
+        result = 31 * result + (secondaryHitInfo?.hashCode() ?: 0)
+        return result
+    }
+
+    public companion object {
+        /*
+         * There's a possibility of ABI mismatch here when the concrete platformAdapter starts receiving
+         * input events with an updated field, such as if a newer source or pointer type has been added
+         * to the underlying platform OS. We need to perform a version check when the platformAdapter is
+         * constructed to ensure that the application doesn't receive anything it wasn't compiled
+         * against.
+         */
+        // TODO: b/343468347 - Implement version check for xr extensions when loading runtime impl
+
+        /** Unknown source. */
+        public const val SOURCE_UNKNOWN: Int = 0
+
+        /**
+         * Event is based on the user's head. Ray origin is at average between eyes, pushed out to
+         * the near clipping plane for both eyes and points in direction head is facing. Action
+         * state is based on volume up button being depressed.
+         *
+         * Events from this source are considered sensitive and hover events are never sent.
+         */
+        public const val SOURCE_HEAD: Int = 1
+
+        /**
+         * Event is based on (one of) the user's controller(s). Ray origin and direction are for a
+         * controller aim pose as defined by
+         * [OpenXR](https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#semantic-paths-standard-pose-identifiers).
+         * Action state is based on the primary button on the controller, usually the bottom-most
+         * face button.
+         */
+        public const val SOURCE_CONTROLLER: Int = 2
+
+        /**
+         * Event is based on one of the user's hands. Ray is a hand aim pose, with origin between
+         * thumb and forefinger and points in direction based on hand orientation. Action state is
+         * based on a pinch gesture.
+         */
+        public const val SOURCE_HANDS: Int = 3
+
+        /**
+         * Event is based on a 2D mouse pointing device. Ray origin behaves the same as for
+         * SOURCE_HEAD and points in direction based on mouse movement. During a drag, the ray
+         * origin moves approximating hand motion. The scrollwheel moves the ray away from / towards
+         * the user. Action state is based on the primary mouse button.
+         */
+        public const val SOURCE_MOUSE: Int = 4
+
+        /**
+         * Event is based on a mix of the head, eyes, and hands. Ray origin is at average between
+         * eyes and points in direction based on a mix of eye gaze direction and hand motion. During
+         * a two-handed zoom/rotate gesture, left/right pointer events will be issued; otherwise,
+         * default events are issued based on the gaze ray. Action state is based on if the user has
+         * done a pinch gesture or not.
+         *
+         * Events from this source are considered sensitive and hover events are never sent.
+         */
+        public const val SOURCE_GAZE_AND_GESTURE: Int = 5
+
+        /**
+         * Default pointer type for the source (no handedness). Occurs for SOURCE_UNKNOWN,
+         * SOURCE_HEAD, SOURCE_MOUSE, and SOURCE_GAZE_AND_GESTURE.
+         */
+        public const val POINTER_TYPE_DEFAULT: Int = 0
+
+        /**
+         * Left hand / controller pointer. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+         * SOURCE_GAZE_AND_GESTURE.
+         */
+        public const val POINTER_TYPE_LEFT: Int = 1
+
+        /**
+         * Right hand / controller pointer. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+         * SOURCE_GAZE_AND_GESTURE.
+         */
+        public const val POINTER_TYPE_RIGHT: Int = 2
+
+        /** The primary action button or gesture was just pressed / started. */
+        public const val ACTION_DOWN: Int = 0
+
+        /**
+         * The primary action button or gesture was just released / stopped. The hit info represents
+         * the node that was originally hit (ie, as provided in the ACTION_DOWN event).
+         */
+        public const val ACTION_UP: Int = 1
+
+        /**
+         * The primary action button or gesture was pressed/active in the previous event, and is
+         * still pressed/active. The hit info represents the node that was originally hit (ie, as
+         * provided in the ACTION_DOWN event). The hit position may be null if the pointer is no
+         * longer hitting that node.
+         */
+        public const val ACTION_MOVE: Int = 2
+
+        /**
+         * While the primary action button or gesture was held, the pointer was disabled. This
+         * happens if you are using controllers and the battery runs out, or if you are using a
+         * source that transitions to a new pointer type, eg SOURCE_GAZE_AND_GESTURE.
+         */
+        public const val ACTION_CANCEL: Int = 3
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray continued to hit
+         * the same node. The hit info represents the node that was hit (may be null if pointer
+         * capture is enabled).
+         *
+         * Hover input events are never provided for sensitive source types.
+         */
+        public const val ACTION_HOVER_MOVE: Int = 4
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray started to hit a
+         * new node. The hit info represents the node that is being hit (may be null if pointer
+         * capture is enabled).
+         *
+         * Hover input events are never provided for sensitive source types.
+         */
+        public const val ACTION_HOVER_ENTER: Int = 5
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray stopped hitting
+         * the node that it was previously hitting. The hit info represents the node that was being
+         * hit (may be null if pointer capture is enabled).
+         *
+         * Hover input events are never provided for sensitive source types.
+         */
+        public const val ACTION_HOVER_EXIT: Int = 6
+    }
+
+    /** The type of the source of this event. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        value =
+            [
+                SOURCE_UNKNOWN,
+                SOURCE_HEAD,
+                SOURCE_CONTROLLER,
+                SOURCE_HANDS,
+                SOURCE_MOUSE,
+                SOURCE_GAZE_AND_GESTURE,
+            ]
+    )
+    internal annotation class Source
+
+    /** The type of the individual pointer. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(value = [POINTER_TYPE_DEFAULT, POINTER_TYPE_LEFT, POINTER_TYPE_RIGHT])
+    internal annotation class PointerType
+
+    // TODO: b/342226522 - Add the HitInfo field to InputEvent.
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        value =
+            [
+                ACTION_DOWN,
+                ACTION_UP,
+                ACTION_MOVE,
+                ACTION_CANCEL,
+                ACTION_HOVER_MOVE,
+                ACTION_HOVER_ENTER,
+                ACTION_HOVER_EXIT,
+            ]
+    )
+    internal annotation class Action
+
+    /**
+     * Information about the hit result of the ray.
+     *
+     * @param inputEntity The entity that was hit by the input ray. <p> ACTION_MOVE, ACTION_UP, and
+     *   ACTION_CANCEL events will report the same node as was hit during the initial ACTION_DOWN.
+     * @param hitPosition The position of the hit in the receiver's activity space. <p> All events
+     *   may report the current ray's hit position. This can be null if there no longer is a
+     *   collision between the ray and the input node (eg, during a drag event).
+     * @param transform The matrix transforming activity space coordinates into the hit entity's
+     *   local coordinate space.
+     */
+    public class HitInfo(
+        public val inputEntity: Entity,
+        public val hitPosition: Vector3?,
+        public val transform: Matrix4,
+    ) {
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is HitInfo) return false
+            if (inputEntity != other.inputEntity) return false
+            if (hitPosition != other.hitPosition) return false
+            if (transform != other.transform) return false
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = inputEntity.hashCode()
+            result = 31 * result + inputEntity.hashCode()
+            result = 31 * result + (hitPosition?.hashCode() ?: 0)
+            result = 31 * result + transform.hashCode()
+            return result
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InteractableComponent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InteractableComponent.kt
new file mode 100644
index 0000000..3ba2924
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/InteractableComponent.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+import java.util.concurrent.Executor
+
+/**
+ * Provides access to raw input events for given Entity, so a client can implement their own
+ * interaction logic.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class InteractableComponent
+private constructor(
+    private val runtime: JxrPlatformAdapter,
+    private val entityManager: EntityManager,
+    private val executor: Executor,
+    private val inputEventListener: InputEventListener,
+) : Component {
+    private val rtInputEventListener =
+        JxrPlatformAdapter.InputEventListener { rtEvent ->
+            inputEventListener.onInputEvent(rtEvent.toInputEvent(entityManager))
+        }
+    private val rtInteractableComponent by lazy {
+        runtime.createInteractableComponent(executor, rtInputEventListener)
+    }
+    private var entity: Entity? = null
+
+    /**
+     * Attaches this component to the given entity.
+     *
+     * @param entity The entity to attach this component to.
+     * @return `true` if the component was successfully attached, `false` otherwise.
+     */
+    override fun onAttach(entity: Entity): Boolean {
+        if (this.entity != null) {
+            Log.e("InteractableComponent", "Already attached to entity ${this.entity}")
+            return false
+        }
+        this.entity = entity
+        return (entity as BaseEntity<*>).rtEntity.addComponent(rtInteractableComponent)
+    }
+
+    /**
+     * Detaches this component from the given entity.
+     *
+     * @param entity The entity to detach this component from.
+     */
+    override fun onDetach(entity: Entity) {
+        (entity as BaseEntity<*>).rtEntity.removeComponent(rtInteractableComponent)
+        this.entity = null
+    }
+
+    internal companion object {
+        /** Factory for Interactable component. */
+        internal fun create(
+            runtime: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            executor: Executor,
+            inputEventListener: InputEventListener,
+        ): InteractableComponent {
+            return InteractableComponent(runtime, entityManager, executor, inputEventListener)
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/JxrPlatformAdapter.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/JxrPlatformAdapter.java
new file mode 100644
index 0000000..f12c28e3
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/JxrPlatformAdapter.java
@@ -0,0 +1,2038 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioTrack;
+import android.media.MediaPlayer;
+import android.media.SoundPool;
+import android.os.Bundle;
+import android.view.Surface;
+import android.view.View;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.arcore.Anchor;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+
+/** Interface for SceneCore Platform operations. This is not intended to be used by Applications. */
+// TODO Add API versioning
+// TODO: b/322549913 - Move subclasses into separate files
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface JxrPlatformAdapter {
+
+    /** Returns the Environment for the Session. */
+    @NonNull
+    SpatialEnvironment getSpatialEnvironment();
+
+    /** A function to create a SceneCore Entity */
+    @NonNull
+    LoggingEntity createLoggingEntity(@NonNull Pose pose);
+
+    /** Returns the Activity Space entity at the root of the scene. */
+    @NonNull
+    ActivitySpace getActivitySpace();
+
+    /** Returns the HeadActivityPose for the Session or null if it is not ready */
+    @Nullable
+    HeadActivityPose getHeadActivityPose();
+
+    /**
+     * Returns the CameraViewActivityPose for the specified camera type or null if it is not
+     * ready/available.
+     */
+    @Nullable
+    CameraViewActivityPose getCameraViewActivityPose(
+            @CameraViewActivityPose.CameraType int cameraType);
+
+    /** Returns the PerceptionSpaceActivityPose for the Session. */
+    @NonNull
+    PerceptionSpaceActivityPose getPerceptionSpaceActivityPose();
+
+    /**
+     * Returns the entity that represents the ActivitySpace root.
+     *
+     * <p>SDK's factory methods are expected to use this entity as the default parent for all
+     * content entities when no parent is specified.
+     */
+    // TODO: b/378680989 - Remove this method.
+    @NonNull
+    Entity getActivitySpaceRootImpl();
+
+    /** Loads glTF Asset for the given asset name from the assets folder. */
+    // Suppressed to allow CompletableFuture.
+    @SuppressWarnings({"AndroidJdkLibsChecker", "AsyncSuffixFuture"})
+    @Nullable
+    ListenableFuture<GltfModelResource> loadGltfByAssetName(@NonNull String assetName);
+
+    /**
+     * Loads glTF Asset for the given asset name from the assets folder using the Split Engine
+     * route. The future returned by this method will fire listeners on the UI thread if
+     * Runnable::run is supplied.
+     */
+    @SuppressWarnings("AsyncSuffixFuture")
+    @Nullable
+    ListenableFuture<GltfModelResource> loadGltfByAssetNameSplitEngine(@NonNull String assetName);
+
+    /** Loads an ExrImage for the given asset name from the assets folder. */
+    // Suppressed to allow CompletableFuture.
+    @SuppressWarnings({"AndroidJdkLibsChecker", "AsyncSuffixFuture"})
+    @Nullable
+    ListenableFuture<ExrImageResource> loadExrImageByAssetName(@NonNull String assetName);
+
+    /**
+     * A factory function to create a SceneCore GltfEntity. The parent may be the activity space or
+     * GltfEntity in the scene.
+     */
+    @NonNull
+    GltfEntity createGltfEntity(
+            @NonNull Pose pose,
+            @NonNull GltfModelResource loadedGltf,
+            @Nullable Entity parentEntity);
+
+    /** A factory function for an Entity which displays drawable surfaces. */
+    @NonNull
+    StereoSurfaceEntity createStereoSurfaceEntity(
+            @StereoSurfaceEntity.StereoMode int stereoMode,
+            @NonNull Dimensions dimensions,
+            @NonNull Pose pose,
+            @NonNull Entity parentEntity);
+
+    /** Return the Spatial Capabilities set that are currently supported by the platform. */
+    @NonNull
+    SpatialCapabilities getSpatialCapabilities();
+
+    /**
+     * Adds the given {@link Consumer} as a listener to be invoked when this Session's current
+     * SpatialCapabilities change. {@link Consumer#accept(SpatialCapabilities)} will be invoked on
+     * the given Executor.
+     */
+    void addSpatialCapabilitiesChangedListener(
+            @NonNull Executor callbackExecutor, @NonNull Consumer<SpatialCapabilities> listener);
+
+    /**
+     * Releases the given {@link Consumer} from receiving updates when the Session's {@link
+     * SpatialCapabilities} change.
+     */
+    void removeSpatialCapabilitiesChangedListener(@NonNull Consumer<SpatialCapabilities> listener);
+
+    /**
+     * If the primary Activity for this Session has focus, causes it to be placed in FullSpace Mode.
+     * Otherwise, this call does nothing.
+     */
+    void requestFullSpaceMode();
+
+    /**
+     * If the primary Activity for this Session has focus, causes it to be placed in HomeSpace Mode.
+     * Otherwise, this call does nothing.
+     */
+    void requestHomeSpaceMode();
+
+    /**
+     * A factory function to create a platform PanelEntity. The parent can be any entity.
+     *
+     * @param pose Initial pose of the panel.
+     * @param view View inflating this panel.
+     * @param surfaceDimensionsPx Dimensions for the underlying surface for the given view.
+     * @param dimensions Size of the panel in meters.
+     * @param name Name of the panel.
+     * @param context Application Context.
+     * @param parent Parent entity.
+     */
+    @NonNull
+    PanelEntity createPanelEntity(
+            @NonNull Pose pose,
+            @NonNull View view,
+            @NonNull PixelDimensions surfaceDimensionsPx,
+            @NonNull Dimensions dimensions,
+            @NonNull String name,
+            @SuppressWarnings("ContextFirst") @NonNull Context context,
+            @NonNull Entity parent);
+
+    /** Get the PanelEntity associated with the main window for the Activity. */
+    @NonNull
+    PanelEntity getMainPanelEntity();
+
+    /**
+     * Factory function to create ActivityPanel to launch/move activity into.
+     *
+     * @param pose Initial pose of the panel.
+     * @param windowBoundsPx Boundary for the window
+     * @param name Name of the panel.
+     * @param hostActivity Activity to host the panel.
+     * @param parent Parent entity.
+     */
+    @NonNull
+    ActivityPanelEntity createActivityPanelEntity(
+            @NonNull Pose pose,
+            @NonNull PixelDimensions windowBoundsPx,
+            @NonNull String name,
+            @NonNull Activity hostActivity,
+            @NonNull Entity parent);
+
+    /**
+     * A factory function to create an Anchor entity.
+     *
+     * @param bounds Bounds for this Anchor.
+     * @param planeType Orientation of the plane to which this anchor should attach.
+     * @param planeSemantic Semantic type of the plane to which this anchor should attach.
+     * @param searchTimeout How long to search for an anchor. If this is Duration.ZERO, this will
+     *     search for an anchor indefinitely.
+     */
+    @NonNull
+    AnchorEntity createAnchorEntity(
+            @NonNull Dimensions bounds,
+            @NonNull PlaneType planeType,
+            @NonNull PlaneSemantic planeSemantic,
+            @NonNull Duration searchTimeout);
+
+    /**
+     * A factory function to create an Anchor entity from a {@link androidx.xr.arcore.Anchor}.
+     *
+     * @param anchor The {@link androidx.xr.arcore.Anchor} to create the Anchor entity from.
+     */
+    @NonNull
+    AnchorEntity createAnchorEntity(@NonNull Anchor anchor);
+
+    /**
+     * Unpersist an AnchorEntity. It will clean up the data in the storage that is required to
+     * retrieve the anchor. Returns whether the anchor was successfully unpersisted.
+     *
+     * @param uuid UUID of the anchor to unpersist.
+     */
+    boolean unpersistAnchor(@NonNull UUID uuid);
+
+    /**
+     * A factory function to create a content-less entity. This entity is used as a connection point
+     * for attaching children entities and managing them (i.e. setPose()) as a group.
+     *
+     * @param pose Initial pose of the entity.
+     * @param name Name of the entity.
+     * @param parent Parent entity.
+     */
+    @NonNull
+    Entity createEntity(@NonNull Pose pose, @NonNull String name, @NonNull Entity parent);
+
+    /**
+     * Create an Interactable component.
+     *
+     * @param executor Executor to use for input callbacks.
+     * @param listener [JxrPlatformAdapter.InputEventListener] for this component.
+     * @return InteractableComponent instance.
+     */
+    @SuppressLint("ExecutorRegistration")
+    @NonNull
+    InteractableComponent createInteractableComponent(
+            @NonNull Executor executor, @NonNull InputEventListener listener);
+
+    /**
+     * Create an instance of [MovableComponent]. This component allows the user to move the entity.
+     *
+     * @param systemMovable A [boolean] which causes the system to automatically apply transform
+     *     updates to the entity in response to user interaction.
+     * @param scaleInZ A [boolean] which tells the system to update the scale of the Entity as the
+     *     user moves it closer and further away. This is mostly useful for Panel auto-rescaling
+     *     with Distance
+     * @param anchorPlacement AnchorPlacement information for when to anchor the entity.
+     * @param shouldDisposeParentAnchor A [boolean] which tells the system to dispose of the parent
+     *     anchor if that entity was created by the moveable component and is moved off of it.
+     * @return [MovableComponent] instance.
+     */
+    @NonNull
+    MovableComponent createMovableComponent(
+            boolean systemMovable,
+            boolean scaleInZ,
+            @NonNull Set<AnchorPlacement> anchorPlacement,
+            boolean shouldDisposeParentAnchor);
+
+    /**
+     * Creates an instance of an AnchorPlacement object.
+     *
+     * <p>This can be used in movable components to specify the anchor placement for the entity.
+     *
+     * @param planeTypeFilter A set of plane types to filter for.
+     * @param planeSemanticFilter A set of plane semantics to filter for.
+     * @return [AnchorPlacement] instance.
+     */
+    @NonNull
+    AnchorPlacement createAnchorPlacementForPlanes(
+            @NonNull Set<PlaneType> planeTypeFilter,
+            @NonNull Set<PlaneSemantic> planeSemanticFilter);
+
+    /**
+     * Create an instance of [ResizableComponent]. This component allows the user to resize the
+     * entity.
+     *
+     * @param minimumSize Minimum size constraint.
+     * @param maximumSize Maximum size constraint.
+     * @return [ResizableComponent] instance.
+     */
+    @NonNull
+    ResizableComponent createResizableComponent(
+            @NonNull Dimensions minimumSize, @NonNull Dimensions maximumSize);
+
+    /**
+     * Create an instance of {@link PointerCaptureComponent}. This component allows the user to
+     * capture and redirect to itself all input that would be received by entities other than the
+     * Entity it is attached to and that entity's children.
+     *
+     * <p>In order to enable pointer capture, an application must be in full space and the entity it
+     * is attached to must be visible.
+     *
+     * <p>Attach this component to the entity to enable pointer capture, detach the component to
+     * restore normal input flow.
+     *
+     * @param executor Executor used to propagate state and input events.
+     * @param stateListener Callback for updates to the state of pointer capture. Pointer capture
+     *     may be temporarily lost by the application for a variety of reasons and this callback
+     *     will notify of when that happens.
+     * @param inputListener Callback that will receive captured [InputEvent]s
+     */
+    @NonNull
+    PointerCaptureComponent createPointerCaptureComponent(
+            @NonNull Executor executor,
+            @NonNull PointerCaptureComponent.StateListener stateListener,
+            @NonNull InputEventListener inputListener);
+
+    /**
+     * A factory function to recreate an Anchor entity which was persisted in a previous session.
+     *
+     * @param uuid The UUID of the persisted anchor.
+     * @param searchTimeout How long to search for an anchor. If this is Duration.ZERO, this will
+     *     search for an anchor indefinitely.
+     */
+    @NonNull
+    AnchorEntity createPersistedAnchorEntity(@NonNull UUID uuid, @NonNull Duration searchTimeout);
+
+    /**
+     * Sets the full space mode flag to the given {@link Bundle}.
+     *
+     * <p>The {@link Bundle} then could be used to launch an {@link Activity} with requesting to
+     * enter full space mode through {@link Activity#startActivity}. If there's a bundle used for
+     * customizing how the {@link Activity} should be started by {@link ActivityOptions.toBundle} or
+     * {@link androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to
+     * call this method.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * bundle, or it is not started from a focused Activity context.
+     *
+     * <p>This flag is also ignored when the {@link android.window.PROPERTY_XR_ACTIVITY_START_MODE}
+     * property is set to a value other than XR_ACTIVITY_START_MODE_UNDEFINED in the
+     * AndroidManifest.xml file for the activity being launched.
+     *
+     * @param bundle the input bundle to set with the full space mode flag.
+     * @return the input {@code bundle} with the full space mode flag set.
+     */
+    @NonNull
+    Bundle setFullSpaceMode(@NonNull Bundle bundle);
+
+    /**
+     * Sets the inherit full space mode environment flag to the given {@link Bundle}.
+     *
+     * <p>The {@link Bundle} then could be used to launch an {@link Activity} with requesting to
+     * enter full space mode while inherit the existing environment through {@link
+     * Activity#startActivity}. If there's a bundle used for customizing how the {@link Activity}
+     * should be started by {@link ActivityOptions.toBundle} or {@link
+     * androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to call
+     * this method.
+     *
+     * <p>When launched, the activity will be in full space mode and also inherits the environment
+     * from the launching activity. If the inherited environment needs to be animated, the launching
+     * activity has to continue updating the environment even after the activity is put into the
+     * stopped state.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * intent, or it is not started from a focused Activity context.
+     *
+     * <p>The flag will also be ignored when there is no environment to inherit or the activity has
+     * its own environment set already.
+     *
+     * <p>This flag is ignored too when the {@link android.window.PROPERTY_XR_ACTIVITY_START_MODE}
+     * property is set to a value other than XR_ACTIVITY_START_MODE_UNDEFINED in the
+     * AndroidManifest.xml file for the activity being launched.
+     *
+     * <p>For security reasons, Z testing for the new activity is disabled, and the activity is
+     * always drawn on top of the inherited environment. Because Z testing is disabled, the activity
+     * should not spatialize itself, and should not curve its panel too much either.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @return the input {@code bundle} with the inherit full space mode flag set.
+     */
+    @NonNull
+    Bundle setFullSpaceModeWithEnvironmentInherited(@NonNull Bundle bundle);
+
+    /**
+     * Sets a preferred main panel aspect ratio for home space mode.
+     *
+     * <p>The ratio is only applied to the activity. If the activity launches another activity in
+     * the same task, the ratio is not applied to the new activity. Also, while the activity is in
+     * full space mode, the preference is temporarily removed.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *     height. A value <= 0.0f means there are no preferences.
+     */
+    void setPreferredAspectRatio(@NonNull Activity activity, float preferredRatio);
+
+    /** Starts the SceneCore renderer. */
+    void startRenderer();
+
+    /** Stops the SceneCore renderer. */
+    void stopRenderer();
+
+    /** Disposes of the resources used by the platform adapter. */
+    void dispose();
+
+    /** Type of plane based on orientation i.e. Horizontal or Vertical. */
+    enum PlaneType {
+        HORIZONTAL,
+        VERTICAL,
+        ANY
+    }
+
+    /** Semantic plane types. */
+    enum PlaneSemantic {
+        WALL,
+        FLOOR,
+        CEILING,
+        TABLE,
+        ANY
+    }
+
+    /** Base interface for all components. */
+    interface Component {
+        /**
+         * Lifecycle event, called when component is attached to an Entity.
+         *
+         * @param entity Entity the component is attached to.
+         * @return True if the component can attach to the given entity.
+         */
+        boolean onAttach(@NonNull Entity entity);
+
+        /**
+         * Lifecycle event, called when component is detached from an Entity.
+         *
+         * @param entity Entity the component detached from.
+         */
+        void onDetach(@NonNull Entity entity);
+    }
+
+    /** Component to enable input interactions. */
+    interface InteractableComponent extends Component {}
+
+    /** Component to enable a high level user movement affordance. */
+    interface MovableComponent extends Component {
+        /**
+         * Modes for scaling the entity as the user moves it closer and further away. *
+         *
+         * <p>DEFAULT: The panel scales in the same way as home space mode.
+         *
+         * <p>DMM: The panel scales in a way that the user-perceived panel size never changes.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    ScaleWithDistanceMode.DEFAULT,
+                    ScaleWithDistanceMode.DMM,
+                })
+        public @interface ScaleWithDistanceMode {
+            int DEFAULT = 3;
+            int DMM = 2;
+        }
+
+        /** Returns the current scale with distance mode. */
+        @ScaleWithDistanceMode
+        int getScaleWithDistanceMode();
+
+        /**
+         * Sets the scale with distance mode.
+         *
+         * @param scaleWithDistanceMode The scale with distance mode to set
+         */
+        void setScaleWithDistanceMode(@ScaleWithDistanceMode int scaleWithDistanceMode);
+
+        /** Sets the size of the interaction highlight extent. */
+        void setSize(@NonNull Dimensions dimensions);
+
+        /**
+         * Adds the listener to the set of active listeners for the move events.
+         *
+         * <p>The listener is invoked on the provided executor. If the app intends to modify the UI
+         * elements/views during the callback, the app should provide the thread executor that is
+         * appropriate for the UI operations. For example, if the app is using the main thread to
+         * render the UI, the app should provide the main thread (Looper.getMainLooper()) executor.
+         * If the app is using a separate thread to render the UI, the app should provide the
+         * executor for that thread.
+         *
+         * @param executor The executor to run the listener on.
+         * @param moveEventListener The move event listener to set.
+         */
+        void addMoveEventListener(
+                @NonNull Executor executor, @NonNull MoveEventListener moveEventListener);
+
+        /**
+         * Removes the listener from the set of active listeners for the move events.
+         *
+         * @param moveEventListener the move event listener to remove
+         */
+        void removeMoveEventListener(@NonNull MoveEventListener moveEventListener);
+    }
+
+    /**
+     * Interface for an AnchorPlacement.
+     *
+     * <p>This is used to set possible conditions in which an entity with a MovableComponent can be
+     * anchored. This can be set with createAnchorPlacementForPlanes.
+     */
+    interface AnchorPlacement {}
+
+    /** Component to enable resize semantics. */
+    interface ResizableComponent extends Component {
+        /**
+         * Sets the size of the entity.
+         *
+         * <p>The size of the entity is the size of the bounding box that contains the content of
+         * the entity. The size of the content inside that bounding box is fully controlled by the
+         * application.
+         *
+         * @param dimensions Dimensions for the Entity in meters.
+         */
+        void setSize(@NonNull Dimensions dimensions);
+
+        /**
+         * Sets the minimum size constraint for the entity.
+         *
+         * <p>The minimum size constraint is used to set constraints on how small the user can
+         * resize the bounding box of the entity up to. The size of the content inside that bounding
+         * box is fully controlled by the application.
+         *
+         * @param minSize Minimum size constraint for the Entity in meters.
+         */
+        void setMinimumSize(@NonNull Dimensions minSize);
+
+        /**
+         * Sets the maximum size constraint for the entity.
+         *
+         * <p>The maximum size constraint is used to set constraints on how large the user can
+         * resize the bounding box of the entity up to. The size of the content inside that bounding
+         * box is fully controlled by the application.
+         *
+         * @param maxSize Maximum size constraint for the Entity in meters.
+         */
+        void setMaximumSize(@NonNull Dimensions maxSize);
+
+        /**
+         * Sets the aspect ratio of the entity during resizing.
+         *
+         * <p>The aspect ratio is determined by taking the panel's width over its height. A value of
+         * 0.0f (or negative) means there are no preferences.
+         *
+         * <p>This method does not immediately resize the entity. The new aspect ratio will be
+         * applied the next time the user resizes the entity through the reform UI. During this
+         * resize operation, the entity's current area will be preserved.
+         *
+         * <p>If a different resizing behavior is desired, such as fixing the width and adjusting
+         * the height, the client can manually resize the entity to the preferred dimensions before
+         * calling this method. No automatic resizing will occur when using the reform UI then.
+         *
+         * @param fixedAspectRatio Aspect ratio during resizing.
+         */
+        void setFixedAspectRatio(float fixedAspectRatio);
+
+        /**
+         * Adds the listener to the set of listeners that are invoked through the resize operation,
+         * such as start, ongoing and end.
+         *
+         * <p>The listener is invoked on the provided executor. If the app intends to modify the UI
+         * elements/views during the callback, the app should provide the thread executor that is
+         * appropriate for the UI operations. For example, if the app is using the main thread to
+         * render the UI, the app should provide the main thread (Looper.getMainLooper()) executor.
+         * If the app is using a separate thread to render the UI, the app should provide the
+         * executor for that thread.
+         *
+         * @param executor The executor to use for the listener callback.
+         * @param resizeEventListener The listener to be invoked when a resize event occurs.
+         */
+        // TODO: b/361638845 - Mirror the Kotlin API for ResizeListener.
+        void addResizeEventListener(
+                @NonNull Executor executor, @NonNull ResizeEventListener resizeEventListener);
+
+        /**
+         * Removes the given listener from the set of listeners for the resize events.
+         *
+         * @param resizeEventListener The listener to be removed.
+         */
+        void removeResizeEventListener(@NonNull ResizeEventListener resizeEventListener);
+    }
+
+    /** Component to enable pointer capture. */
+    interface PointerCaptureComponent extends Component {
+        public static final int POINTER_CAPTURE_STATE_PAUSED = 0;
+        public static final int POINTER_CAPTURE_STATE_ACTIVE = 1;
+        public static final int POINTER_CAPTURE_STATE_STOPPED = 2;
+
+        /** The possible states of pointer capture. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    POINTER_CAPTURE_STATE_PAUSED,
+                    POINTER_CAPTURE_STATE_ACTIVE,
+                    POINTER_CAPTURE_STATE_STOPPED,
+                })
+        public @interface PointerCaptureState {}
+
+        /** Functional interface for receiving updates about the state of pointer capture. */
+        interface StateListener {
+            void onStateChanged(@PointerCaptureState int newState);
+        }
+    }
+
+    /** Interface for a SceneCore resource. A resource represents a loadable resource. */
+    interface Resource {}
+
+    /**
+     * Interface for an EXR resource. These HDR images can be used for image based lighting and
+     * skyboxes.
+     */
+    interface ExrImageResource extends Resource {}
+
+    /** Interface for a glTF resource. This can be used for creating glTF entities. */
+    interface GltfModelResource extends Resource {}
+
+    /** Interface for Input listener. */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @FunctionalInterface
+    interface InputEventListener {
+        void onInputEvent(@NonNull InputEvent event);
+    }
+
+    /** Interface for MoveEvent listener. */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @FunctionalInterface
+    interface MoveEventListener {
+        void onMoveEvent(@NonNull final MoveEvent event);
+    }
+
+    /** Interface for ResizeEvent listener. */
+    @SuppressWarnings("AndroidJdkLibsChecker")
+    @FunctionalInterface
+    interface ResizeEventListener {
+        void onResizeEvent(@NonNull final ResizeEvent event);
+    }
+
+    /** Interface for a SceneCore ActivityPose */
+    interface ActivityPose {
+        /** Returns the pose for this entity, relative to the activity space root. */
+        @NonNull
+        Pose getActivitySpacePose();
+
+        // TODO: b/364303733 - Consider deprecating this method.
+        /**
+         * Returns the scale of this ActivityPose. For base ActivityPoses, the scale is (1,1,1). For
+         * entities this returns the accumulated scale. This value includes the parent's scale, and
+         * is similar to a ActivitySpace scale.
+         *
+         * @return Total [Vector3] scale applied to self and children.
+         */
+        @NonNull
+        Vector3 getWorldSpaceScale();
+
+        /**
+         * Returns the scale of this WorldPose relative to the activity space. This returns the
+         * accumulated scale which includes the parent's scale, but does not include the scale of
+         * the activity space itself.
+         *
+         * @return Total [Vector3] scale applied to self and children relative to the activity
+         *     space.
+         */
+        @NonNull
+        Vector3 getActivitySpaceScale();
+
+        /**
+         * Returns a pose relative to this entity transformed into a pose relative to the
+         * destination.
+         *
+         * @param pose A pose in this entity's local coordinate space.
+         * @param destination The entity which the returned pose will be relative to.
+         * @return The pose relative to the destination entity.
+         */
+        @NonNull
+        Pose transformPoseTo(@NonNull Pose pose, @NonNull ActivityPose destination);
+    }
+
+    /** Interface for a SceneCore head ActivityPose. This is the position of the user's head. */
+    interface HeadActivityPose extends ActivityPose {}
+
+    /**
+     * Interface for a SceneCore camera view ActivityPose. This is the position of a user's camera.
+     *
+     * <p>The camera's field of view can be retrieved from this CameraViewActivityPose.
+     */
+    interface CameraViewActivityPose extends ActivityPose {
+        public static final int CAMERA_TYPE_UNKNOWN = 0;
+        public static final int CAMERA_TYPE_LEFT_EYE = 1;
+        public static final int CAMERA_TYPE_RIGHT_EYE = 2;
+
+        /** Returns the type of camera that this space represents. */
+        @CameraType
+        public int getCameraType();
+
+        /**
+         * The angles (in radians) representing the sides of the view frustum. These are not
+         * expected to change over the lifetime of the session but in rare cases may change due to
+         * updated camera settings
+         */
+        static class Fov {
+
+            public final float angleLeft;
+            public final float angleRight;
+            public final float angleUp;
+            public final float angleDown;
+
+            public Fov(float angleLeft, float angleRight, float angleUp, float angleDown) {
+                this.angleLeft = angleLeft;
+                this.angleRight = angleRight;
+                this.angleUp = angleUp;
+                this.angleDown = angleDown;
+            }
+        }
+
+        @NonNull
+        Fov getFov();
+
+        /** Describes the type of camera that this space represents. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    CAMERA_TYPE_UNKNOWN,
+                    CAMERA_TYPE_LEFT_EYE,
+                    CAMERA_TYPE_RIGHT_EYE,
+                })
+        public @interface CameraType {}
+    }
+
+    /**
+     * Interface for the perception space ActivityPose. This is the origin of the space used by
+     * ARCore for XR.
+     */
+    interface PerceptionSpaceActivityPose extends ActivityPose {}
+
+    /** Interface for a SceneCore Entity */
+    interface Entity extends ActivityPose {
+
+        /** Returns the pose for this entity, relative to its parent. */
+        @NonNull
+        Pose getPose();
+
+        /** Updates the pose (position and rotation) of the Entity relative to its parent. */
+        void setPose(@NonNull Pose pose);
+
+        /**
+         * Returns the scale of this entity, relative to its parent. Note that this doesn't include
+         * the parent's scale.
+         *
+         * @return Current [Vector3] scale applied to self and children.
+         */
+        @NonNull
+        Vector3 getScale();
+
+        /**
+         * Sets the scale of this entity relative to its parent. This value will affect the
+         * rendering of this Entity's children. As the scale increases, this will stretch the
+         * content of the Entity.
+         *
+         * @param scale The [Vector3] scale factor from the parent.
+         */
+        void setScale(@NonNull Vector3 scale);
+
+        /**
+         * Add given Entity as child. The child Entity's pose will be relative to the pose of its
+         * parent
+         *
+         * @param child The child entity.
+         */
+        void addChild(@NonNull Entity child);
+
+        // Sets the provided Entities to be children of the Entity.
+        void addChildren(@NonNull List<Entity> children);
+
+        // Returns the parent entity for this Entity.
+        @Nullable
+        Entity getParent();
+
+        // Sets the parent Entity for this Entity. The child Entity's pose will be relative to the
+        // pose of its parent.
+        void setParent(@Nullable Entity parent);
+
+        // Sets context-text for this entity to be consumed by Accessibility systems.
+        void setContentDescription(@NonNull String text);
+
+        // Returns the all child entities of this Entity.
+        @NonNull
+        List<Entity> getChildren();
+
+        /**
+         * Sets the size for the given Entity.
+         *
+         * @param dimensions Dimensions for the Entity in meters.
+         */
+        void setSize(@NonNull Dimensions dimensions);
+
+        /** Returns the set alpha transparency level for this Entity. */
+        float getAlpha();
+
+        /**
+         * Sets the alpha transparency for the given Entity.
+         *
+         * @param alpha Alpha transparency level for the Entity.
+         */
+        void setAlpha(float alpha);
+
+        /** Returns the total alpha transparency level for this Entity. */
+        float getActivitySpaceAlpha();
+
+        /**
+         * Sets the local hidden state of this Entity. When true, this Entity and all descendants
+         * will not be rendered in the scene. When the hidden state is false, an entity will be
+         * rendered if its ancestors are not hidden.
+         *
+         * @param hidden The new local hidden state of this Entity.
+         */
+        void setHidden(boolean hidden);
+
+        /**
+         * Returns the hidden status of this Entity.
+         *
+         * @param includeParents Whether to include the hidden status of parents in the returned
+         *     value.
+         * @return If includeParents is true, the returned value will be true if this Entity or any
+         *     of its ancestors is hidden. If includeParents is false, the local hidden state is
+         *     returned. Regardless of the local hidden state, an entity will not be rendered if any
+         *     of its ancestors are hidden.
+         */
+        boolean isHidden(boolean includeParents);
+
+        /**
+         * Adds the listener to the set of active input listeners, for input events targeted to this
+         * entity or its child entities.
+         *
+         * @param executor The executor to run the listener on.
+         * @param listener The input event listener to add.
+         */
+        void addInputEventListener(
+                @NonNull Executor executor, @NonNull InputEventListener listener);
+
+        /** Removes the given listener from the set of active input listeners. */
+        void removeInputEventListener(@NonNull InputEventListener listener);
+
+        /**
+         * Dispose any system resources held by this entity, and transitively calls dispose() on all
+         * the children. Once disposed, Entity shouldn't be used again.
+         */
+        void dispose();
+
+        /**
+         * Add these components to entity.
+         *
+         * @param component Component to add to the Entity.
+         * @return True if the given component is added to the Entity.
+         */
+        boolean addComponent(@NonNull Component component);
+
+        /**
+         * Remove the given component from the entity.
+         *
+         * @param component Component to remove from the entity.
+         */
+        void removeComponent(@NonNull Component component);
+
+        /** Remove all components from this entity. */
+        void removeAllComponents();
+    }
+
+    /**
+     * Interface for updating the background image/geometry and passthrough settings.
+     *
+     * <p>The application can set either / both a skybox and a glTF for geometry, then toggle their
+     * visibility by enabling or disabling passthrough. The skybox and geometry will be remembered
+     * across passthrough mode changes.
+     */
+    interface SpatialEnvironment {
+
+        /** A class that represents the user's preferred spatial environment. */
+        class SpatialEnvironmentPreference {
+            /**
+             * The preferred geometry for the environment based on a pre-loaded glTF model. If null,
+             * there will be no geometry
+             */
+            @Nullable public final GltfModelResource geometry;
+
+            /**
+             * The preferred skybox for the environment based on a pre-loaded EXR Image. If null, it
+             * will be all black.
+             */
+            @Nullable public final ExrImageResource skybox;
+
+            public SpatialEnvironmentPreference(
+                    @Nullable ExrImageResource skybox, @Nullable GltfModelResource geometry) {
+                this.skybox = skybox;
+                this.geometry = geometry;
+            }
+
+            @Override
+            public boolean equals(@Nullable Object o) {
+                if (o == this) {
+                    return true;
+                }
+                if (o instanceof SpatialEnvironmentPreference) {
+                    SpatialEnvironmentPreference other = (SpatialEnvironmentPreference) o;
+                    return Objects.equals(other.skybox, skybox)
+                            && Objects.equals(other.geometry, geometry);
+                }
+                return false;
+            }
+
+            @Override
+            public int hashCode() {
+                return Objects.hash(skybox, geometry);
+            }
+        }
+
+        /**
+         * Sets the preference for passthrough state by requesting a change in passthrough opacity.
+         *
+         * <p>Passthrough visibility cannot be set directly to on/off modes. Instead, a desired
+         * passthrough opacity value between 0.0f and 1.0f can be requested which will dictate which
+         * mode is used. A passthrough opacity within 0.01f of 0.0f will disable passthrough, and
+         * will be returned as 0.0f by [getPassthroughOpacityPreference]. An opacity value within
+         * 0.01f of 1.0f will enable full passthrough and it will be returned as 1.0f by
+         * [getPassthroughOpacityPreference]. Any other value in the range will result in a
+         * semi-transparent passthrough.
+         *
+         * <p>Requesting to set passthrough opacity to a value that is not in the range of 0.0f to
+         * 1.0f will result in the value getting clamped to 0.0f or 1.0f depending on which one is
+         * closer.
+         *
+         * <p>If the value is set to null, the opacity will be managed by the system.
+         *
+         * <p>Requests to change opacity are only immediately attempted to be honored if the
+         * activity has the [SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL] capability.
+         * When the request is honored, this returns [SetPassthroughOpacityPreferenceChangeApplied].
+         * When the activity does not have the capability to control the passthrough state, this
+         * returns [SetPassthroughOpacityPreferenceChangePending] to indicate that the application
+         * passthrough opacity preference has been set and is pending to be automatically applied
+         * when the app regains capabilities to control passthrough state.
+         *
+         * <p>When passthrough state changes, whether due to this request succeeding or due to any
+         * other system or user initiated change, [OnPassthroughOpacityChangedListener] will be
+         * notified.
+         */
+        @CanIgnoreReturnValue
+        @NonNull
+        public SetPassthroughOpacityPreferenceResult setPassthroughOpacityPreference(
+                @SuppressWarnings("AutoBoxing") @Nullable Float passthroughOpacityPreference);
+
+        /**
+         * Gets the current passthrough opacity value between 0 and 1 where 0.0f means no
+         * passthrough, and 1.0f means full passthrough.
+         *
+         * <p>This value can be overwritten by user-enabled or system-enabled passthrough and will
+         * not always match the opacity value returned by [getPassthroughOpacityPreference].
+         */
+        public float getCurrentPassthroughOpacity();
+
+        /**
+         * Gets the last passthrough opacity requested through [setPassthroughOpacityPreference].
+         *
+         * <p>This may be different from the actual current state returned by
+         * [getCurrentPassthroughOpacity], but it should be applied as soon as the
+         * [SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL] capability is gained.
+         * Defaults to null, if [setPassthroughOpacityPreference] was never called.
+         *
+         * <p>If set to null, the passthrough opacity will default to the user preference managed
+         * through the system.
+         */
+        @SuppressWarnings("AutoBoxing")
+        @Nullable
+        public Float getPassthroughOpacityPreference();
+
+        /**
+         * Notifies an application when the passthrough state changes, such as when the application
+         * enters or exits passthrough or when the passthrough opacity changes. This [listener] will
+         * be called on the Application's UI thread.
+         */
+        public void addOnPassthroughOpacityChangedListener(@NonNull Consumer<Float> listener);
+
+        /** Remove a listener previously added by [addOnPassthroughOpacityChangedListener]. */
+        public void removeOnPassthroughOpacityChangedListener(@NonNull Consumer<Float> listener);
+
+        /**
+         * Returns true if the environment set by [setSpatialEnvironmentPreference] is active.
+         *
+         * <p>Spatial environment preference set through [setSpatialEnvironmentPreference] are shown
+         * when this is true, but passthrough or other objects in the scene could partially or
+         * totally occlude them. When this is false, the default system environment will be active
+         * instead.
+         */
+        boolean isSpatialEnvironmentPreferenceActive();
+
+        /**
+         * Sets the preferred spatial environment for the application.
+         *
+         * <p>Note that this method only sets a preference and does not cause an immediate change
+         * unless [isSpatialEnvironmentPreferenceActive] is already true. Once the device enters a
+         * state where the XR background can be changed and the
+         * [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS] capability is available, the
+         * preferred spatial environment for the application will be automatically displayed.
+         *
+         * <p>Setting the preference to null will disable the preferred spatial environment for the
+         * application, meaning the default system environment will be displayed instead.
+         *
+         * <p>If the given [SpatialEnvironmentPreference] is not null, but all of its properties are
+         * null, then the spatial environment will consist of a black skybox and no geometry
+         * [isSpatialEnvironmentPreferenceActive] is true.
+         *
+         * <p>Changes to the Environment state will be notified via the
+         * [OnSpatialEnvironmentChangedListener].
+         */
+        @NonNull
+        @CanIgnoreReturnValue
+        SetSpatialEnvironmentPreferenceResult setSpatialEnvironmentPreference(
+                @Nullable SpatialEnvironmentPreference preference);
+
+        /**
+         * Gets the preferred spatial environment for the application.
+         *
+         * <p>The returned value is always what was most recently supplied to
+         * [setSpatialEnvironmentPreference], or null if no preference has been set.
+         *
+         * <p>See [isSpatialEnvironmentPreferenceActive] or the
+         * [OnSpatialEnvironmentChangedListener] events to know when this preference becomes active.
+         */
+        @Nullable
+        SpatialEnvironmentPreference getSpatialEnvironmentPreference();
+
+        /**
+         * Notifies an application whether or not the preferred spatial environment for the
+         * application is active.
+         *
+         * <p>The environment will try to transition to the application environment when a non-null
+         * preference is set through [setSpatialEnvironmentPreference] and the application has the
+         * [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS] capability. The environment
+         * preferences will otherwise not be active.
+         *
+         * <p>The listener consumes a boolean value that is true if the environment preference is
+         * active when the listener is notified.
+         *
+         * <p>This listener will be invoked on the Application's UI thread.
+         */
+        void addOnSpatialEnvironmentChangedListener(@NonNull Consumer<Boolean> listener);
+
+        /** Remove a listener previously added by [addOnSpatialEnvironmentChangedListener]. */
+        void removeOnSpatialEnvironmentChangedListener(@NonNull Consumer<Boolean> listener);
+
+        /** Result values for calls to SpatialEnvironment.setPassthroughOpacityPreference */
+        enum SetPassthroughOpacityPreferenceResult {
+            /**
+             * The call to [setPassthroughOpacityPreference] succeeded and should now be visible.
+             */
+            CHANGE_APPLIED,
+
+            /**
+             * The preference has been set, but will be applied only when the
+             * [SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL] is acquired
+             */
+            CHANGE_PENDING,
+        }
+
+        /** Result values for calls to SpatialEnvironment.setSpatialEnvironmentPreference */
+        enum SetSpatialEnvironmentPreferenceResult {
+            /**
+             * The call to [setSpatialEnvironmentPreference] succeeded and should now be visible.
+             */
+            CHANGE_APPLIED,
+
+            /**
+             * The call to [setSpatialEnvironmentPreference] successfully applied the preference,
+             * but it is not immediately visible due to requesting a state change while the activity
+             * does not have the [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS]
+             * capability to control the app environment state. The preference was still set and
+             * will be applied when the capability is gained.
+             */
+            CHANGE_PENDING,
+        }
+    }
+
+    /** Interface for a SceneCore Entity that only logs the pose. */
+    interface LoggingEntity extends Entity {}
+
+    /** Interface for a system-controlled SceneCore Entity that defines its own coordinate space. */
+    interface SystemSpaceEntity extends Entity {
+        /**
+         * Registers a listener to be called when the underlying space has moved or changed.
+         *
+         * @param listener The listener to register if non-null, else stops listening if null.
+         * @param executor The executor to run the listener on. Defaults to SceneCore executor if
+         *     null.
+         */
+        void setOnSpaceUpdatedListener(
+                @Nullable OnSpaceUpdatedListener listener, @Nullable Executor executor);
+
+        /** Interface for a listener which receives changes to the underlying space. */
+        @SuppressWarnings("AndroidJdkLibsChecker")
+        @FunctionalInterface
+        interface OnSpaceUpdatedListener {
+            void onSpaceUpdated();
+        }
+    }
+
+    /**
+     * Interface for a SceneCore activity space. There is one activity space and it is the ancestor
+     * for all elements in the scene. The activity space does not have a parent.
+     */
+    interface ActivitySpace extends SystemSpaceEntity {
+
+        /** Returns the bounds of this ActivitySpace. */
+        @NonNull
+        Dimensions getBounds();
+
+        /**
+         * Adds a listener to be called when the bounds of the primary Activity change. If the same
+         * listener is added multiple times, it will only fire each event on time.
+         *
+         * @param listener The listener to register.
+         */
+        @SuppressWarnings("ExecutorRegistration")
+        void addOnBoundsChangedListener(@NonNull OnBoundsChangedListener listener);
+
+        /**
+         * Removes a listener to be called when the bounds of the primary Activity change. If the
+         * given listener was not added, this call does nothing.
+         *
+         * @param listener The listener to unregister.
+         */
+        void removeOnBoundsChangedListener(@NonNull OnBoundsChangedListener listener);
+
+        /**
+         * Interface for a listener which receives changes to the bounds of the primary Activity.
+         */
+        interface OnBoundsChangedListener {
+            // Is called by the system when the bounds of the primary Activity change
+            /**
+             * Called by the system when the bounds of the primary Activity change.
+             *
+             * @param bounds The new bounds of the primary Activity in Meters
+             */
+            void onBoundsChanged(@NonNull Dimensions bounds);
+        }
+    }
+
+    /** Interface for a SceneCore [GltfEntity]. */
+    interface GltfEntity extends Entity {
+        // TODO: b/362368652 - Add an OnAnimationFinished() Listener interface
+        //                     Add a getAnimationTimeRemaining() interface
+
+        /** Specifies the current animation state of the [GltfEntity]. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({AnimationState.PLAYING, AnimationState.STOPPED})
+        public @interface AnimationState {
+            public static final int PLAYING = 0;
+            public static final int STOPPED = 1;
+        }
+
+        /**
+         * Starts the animation with the given name.
+         *
+         * @param animationName The name of the animation to start. If null is supplied, will play
+         *     the first animation found in the glTF.
+         * @param loop Whether the animation should loop.
+         */
+        void startAnimation(boolean loop, @Nullable String animationName);
+
+        /** Stops the animation of the glTF entity. */
+        void stopAnimation();
+
+        /** Returns the current animation state of the glTF entity. */
+        @AnimationState
+        int getAnimationState();
+    }
+
+    /** Interface for a SceneCore Panel entity */
+    interface PanelEntity extends Entity {
+        /**
+         * Returns the dimensions of the view underlying this PanelEntity.
+         *
+         * @return The current [PixelDimensions] of the underlying surface.
+         */
+        @NonNull
+        PixelDimensions getPixelDimensions();
+
+        /**
+         * Sets the pixel (not Dp) dimensions of the view underlying this PanelEntity. Calling this
+         * might cause the layout of the Panel contents to change. Updating this will not cause the
+         * scale or pixel density to change.
+         *
+         * @param dimensions The [PixelDimensions] of the underlying surface to set.
+         */
+        void setPixelDimensions(@NonNull PixelDimensions dimensions);
+
+        /**
+         * Sets a corner radius on all four corners of this PanelEntity.
+         *
+         * @param value Corner radius in meters.
+         * @throws IllegalArgumentException if radius is <= 0.0f.
+         */
+        void setCornerRadius(float value);
+
+        /** Gets the corner radius of this PanelEntity in meters. Has a default value of 0. */
+        float getCornerRadius();
+
+        /**
+         * Gets the number of pixels per meter for this panel. This value reflects changes to scale,
+         * including parent scale.
+         *
+         * @return Vector3 scale applied to pixels within the Panel. (Z will be 0)
+         */
+        @NonNull
+        Vector3 getPixelDensity();
+
+        /**
+         * Returns the spatial size of this Panel in meters. This includes any scaling applied to
+         * this panel by itself or its parents, which might be set via changes to setScale.
+         *
+         * @return [Dimensions] size of this panel in meters. (Z will be 0)
+         */
+        @NonNull
+        Dimensions getSize();
+    }
+
+    /** Interface for a SceneCore ActivityPanel entity. */
+    interface ActivityPanelEntity extends PanelEntity {
+        /**
+         * Launches the given activity into the panel.
+         *
+         * @param intent Intent to launch the activity.
+         * @param bundle Bundle to pass to the activity, can be null.
+         */
+        void launchActivity(@NonNull Intent intent, @Nullable Bundle bundle);
+
+        /**
+         * Moves the given activity into the panel.
+         *
+         * @param activity Activity to move into the ActivityPanel.
+         */
+        void moveActivity(@NonNull Activity activity);
+    }
+
+    /** Interface for a surface which images can be rendered into. */
+    interface StereoSurfaceEntity extends Entity {
+        /**
+         * Selects the view configuration for the surface. MONO creates a surface contains a single
+         * view. SIDE_BY_SIDE means the surface is split in half with two views. The first half of
+         * the surface maps to the left eye and the second half mapping to the right eye.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({StereoMode.MONO, StereoMode.TOP_BOTTOM, StereoMode.SIDE_BY_SIDE})
+        public @interface StereoMode {
+            // Each eye will see the entire surface (no separation)
+            public static final int MONO = 0;
+            // The [top, bottom] halves of the surface will map to [left, right] eyes
+            public static final int TOP_BOTTOM = 1;
+            // The [left, right] halves of the surface will map to [left, right] eyes
+            public static final int SIDE_BY_SIDE = 2;
+        }
+
+        /**
+         * Specifies how the surface content will be routed for stereo viewing. Applications must
+         * render into the surface in accordance with what is specified here in order for the
+         * compositor to correctly produce a stereoscopic view to the user.
+         *
+         * @param mode An int StereoMode
+         */
+        void setStereoMode(@StereoMode int mode);
+
+        /**
+         * Retrieves the StereoMode for this Entity.
+         *
+         * @return An int StereoMode
+         */
+        @StereoMode
+        int getStereoMode();
+
+        /**
+         * Changes the width and height of the "spatial canvas" which the surface is mapped to.
+         * (0,0,0) for this Entity is the canvas's center. (In ActivitySpace, this is
+         * 1/getWorldSpaceScale() unit per meter) (Z is ignored)
+         *
+         * @param dimensions A [Dimensions] to set the canvas size.
+         */
+        void setDimensions(@NonNull Dimensions dimensions);
+
+        /**
+         * Retrieves the dimensions of the "spatial canvas" which the surface is mapped to. These
+         * values are not impacted by scale.
+         *
+         * @return The canvas [Dimensions].
+         */
+        @NonNull
+        Dimensions getDimensions();
+
+        /**
+         * Retrieves the surface that the Entity will display. The app can write into this surface
+         * however it wants, i.e. MediaPlayer, ExoPlayer, or custom rendering.
+         *
+         * @return an Android [Surface]
+         */
+        @NonNull
+        Surface getSurface();
+    }
+
+    /** Interface for Anchor entity. */
+    interface AnchorEntity extends SystemSpaceEntity {
+        /* Returns the current state of the anchor synchronously.*/
+        @NonNull
+        State getState();
+
+        /** Registers a listener to be called when the state of the anchor changes. */
+        @SuppressWarnings("ExecutorRegistration")
+        void setOnStateChangedListener(@Nullable OnStateChangedListener onStateChangedListener);
+
+        /**
+         * Persists the anchor. If the query is sent to perception service successful returns an
+         * UUID, which could be used retrieve the anchor. Otherwise, return null.
+         */
+        @Nullable
+        UUID persist();
+
+        /** Returns the current persist state of the anchor synchronously. */
+        @NonNull
+        PersistState getPersistState();
+
+        /** Returns the native pointer of the anchor. */
+        // TODO(b/373711152) : Remove this method once the Jetpack XR Runtime API migration is done.
+        long nativePointer();
+
+        /** Registers a listener to be called when the persist state of the anchor changes. */
+        @SuppressWarnings({"ExecutorRegistration", "PairedRegistration"})
+        void registerPersistStateChangeListener(
+                @NonNull PersistStateChangeListener persistStateChangeListener);
+
+        /** Specifies the current tracking state of the Anchor. */
+        enum State {
+            /**
+             * An UNANCHORED state could mean that the perception stack hasn't found an anchor for
+             * this Space, that it has lost tracking.
+             */
+            UNANCHORED,
+            /**
+             * The ANCHORED state means that this Anchor is being actively tracked and updated by
+             * the perception stack. The application should expect children to maintain their
+             * relative positioning to the system's best understanding of a pose in the real world.
+             */
+            ANCHORED,
+            /**
+             * The AnchorEntity timed out while searching for an underlying anchor. This it is not
+             * possible to recover the AnchorEntity.
+             */
+            TIMED_OUT,
+            /**
+             * The ERROR state means that something has gone wrong and this AnchorSpace is invalid
+             * without the possibility of recovery.
+             */
+            ERROR,
+        }
+
+        /** Specifies the current persistence state of the Anchor. */
+        enum PersistState {
+            /** The anchor hasn't been requested to persist. */
+            PERSIST_NOT_REQUESTED,
+            /** The anchor is requested to persist but hasn't been persisted yet. */
+            PERSIST_PENDING,
+            /** The anchor is persisted successfully. */
+            PERSISTED,
+        }
+
+        /** Interface for listening to Anchor state changes. */
+        interface OnStateChangedListener {
+            void onStateChanged(@NonNull State newState);
+        }
+
+        /** Interface for listening to Anchor persist state changes. */
+        interface PersistStateChangeListener {
+            void onPersistStateChanged(@NonNull PersistState newPersistState);
+        }
+    }
+
+    /** The dimensions of a UI element in pixels. These are always two dimensional. */
+    class PixelDimensions {
+        public final int width;
+        public final int height;
+
+        public PixelDimensions(int width, int height) {
+            this.width = width;
+            this.height = height;
+        }
+
+        @Override
+        public String toString() {
+            return super.toString() + ": w " + width + " x h " + height;
+        }
+    }
+
+    /** The dimensions of a UI element in meters. */
+    class Dimensions {
+        // TODO: b/332588978 - Add a TypeAlias for Meters here.
+        @SuppressWarnings("MutableBareField")
+        public float width;
+
+        @SuppressWarnings("MutableBareField")
+        public float height;
+
+        @SuppressWarnings("MutableBareField")
+        public float depth;
+
+        public Dimensions(float width, float height, float depth) {
+            this.width = width;
+            this.height = height;
+            this.depth = depth;
+        }
+
+        @Override
+        public String toString() {
+            return super.toString() + ": w " + width + " x h " + height + " x d " + depth;
+        }
+    }
+
+    /** Ray in 3D Cartesian space. */
+    class Ray {
+        @NonNull public final Vector3 origin;
+        @NonNull public final Vector3 direction;
+
+        public Ray(@NonNull Vector3 origin, @NonNull Vector3 direction) {
+            this.origin = origin;
+            this.direction = direction;
+        }
+    }
+
+    /** MoveEvent for SceneCore Platform. */
+    class MoveEvent {
+        // TODO: b/350370142 - Use public getter/setter interfaces instead of public fields.
+        public static final int MOVE_STATE_START = 1;
+        public static final int MOVE_STATE_ONGOING = 2;
+        public static final int MOVE_STATE_END = 3;
+
+        /** State of the move action. */
+        @MoveState public final int moveState;
+
+        /** Initial ray origin and direction in activity space. */
+        @NonNull public final Ray initialInputRay;
+
+        /** Current ray origin and direction in activity space. */
+        @NonNull public final Ray currentInputRay;
+
+        /** Previous pose of the entity, relative to its parent. */
+        @NonNull public final Pose previousPose;
+
+        /** Current pose of the entity, relative to its parent. */
+        @NonNull public final Pose currentPose;
+
+        /** Previous scale of the entity. */
+        @NonNull public final Vector3 previousScale;
+
+        /** Current scale of the entity. */
+        @NonNull public final Vector3 currentScale;
+
+        /** Initial Parent of the entity at the start of the move. */
+        @NonNull public final Entity initialParent;
+
+        /** Updates parent of the entity at the end of the move or null if not updated. */
+        @Nullable public final Entity updatedParent;
+
+        /**
+         * Reports an entity that was disposed and needs to be removed from the sdk EntityManager.
+         */
+        @Nullable public final Entity disposedEntity;
+
+        public MoveEvent(
+                int moveState,
+                @NonNull Ray initialInputRay,
+                @NonNull Ray currentInputRay,
+                @NonNull Pose previousPose,
+                @NonNull Pose currentPose,
+                @NonNull Vector3 previousScale,
+                @NonNull Vector3 currentScale,
+                @NonNull Entity initialParent,
+                @Nullable Entity updatedParent,
+                @Nullable Entity disposedEntity) {
+            this.moveState = moveState;
+            this.initialInputRay = initialInputRay;
+            this.currentInputRay = currentInputRay;
+            this.previousPose = previousPose;
+            this.currentPose = currentPose;
+            this.previousScale = previousScale;
+            this.currentScale = currentScale;
+            this.initialParent = initialParent;
+            this.updatedParent = updatedParent;
+            this.disposedEntity = disposedEntity;
+        }
+
+        /** States of the Move action. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    MOVE_STATE_START,
+                    MOVE_STATE_ONGOING,
+                    MOVE_STATE_END,
+                })
+        public @interface MoveState {}
+    }
+
+    /** ResizeEvent for SceneCore Platform. */
+    class ResizeEvent {
+        public static final int RESIZE_STATE_UNKNOWN = 0;
+        public static final int RESIZE_STATE_START = 1;
+        public static final int RESIZE_STATE_ONGOING = 2;
+        public static final int RESIZE_STATE_END = 3;
+
+        /**
+         * Proposed (width, height, depth) size in meters. The resize event listener must use this
+         * proposed size to resize the content.
+         */
+        @NonNull public final Dimensions newSize;
+
+        /** Current state of the Resize action. */
+        @ResizeState public final int resizeState;
+
+        public ResizeEvent(@ResizeState int resizeState, @NonNull Dimensions newSize) {
+            this.resizeState = resizeState;
+            this.newSize = newSize;
+        }
+
+        /** States of the Resize action. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    RESIZE_STATE_UNKNOWN,
+                    RESIZE_STATE_START,
+                    RESIZE_STATE_ONGOING,
+                    RESIZE_STATE_END,
+                })
+        public @interface ResizeState {}
+    }
+
+    /** InputEvent for SceneCore Platform. */
+    class InputEvent {
+        /**
+         * There's a possibility of ABI mismatch here when the concrete platformAdapter starts
+         * receiving input events with an updated field, such as if a newer source or pointer type
+         * has been added to the underlying platform OS. We need to perform a version check when the
+         * platformAdapter is constructed to ensure that the application doesn't receive anything it
+         * wasn't compiled against.
+         */
+        // TODO: b/343468347 - Implement a version check for xr extensions when creating the
+        // concrete
+        // platform adapter.
+
+        /** Unknown source. */
+        public static final int SOURCE_UNKNOWN = 0;
+
+        /**
+         * Event is based on the user's head. Ray origin is at average between eyes, pushed out to
+         * the near clipping plane for both eyes and points in direction head is facing. Action
+         * state is based on volume up button being depressed.
+         *
+         * <p>Events from this source are considered sensitive and hover events are never sent.
+         */
+        public static final int SOURCE_HEAD = 1;
+
+        /**
+         * Event is based on (one of) the user's controller(s). Ray origin and direction are for a
+         * controller aim pose as defined by OpenXR. (<a
+         * href="https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#semantic-paths-standard-pose-identifiers">...</a>)
+         * Action state is based on the primary button on the controller, usually the bottom-most
+         * face button.
+         */
+        public static final int SOURCE_CONTROLLER = 2;
+
+        /**
+         * Event is based on one of the user's hands. Ray is a hand aim pose, with origin between
+         * thumb and forefinger and points in direction based on hand orientation. Action state is
+         * based on a pinch gesture.
+         */
+        public static final int SOURCE_HANDS = 3;
+
+        /**
+         * Event is based on a 2D mouse pointing device. Ray origin behaves the same as for
+         * DEVICE_TYPE_HEAD and points in direction based on mouse movement. During a drag, the ray
+         * origin moves approximating hand motion. The scrollwheel moves the ray away from / towards
+         * the user. Action state is based on the primary mouse button.
+         */
+        public static final int SOURCE_MOUSE = 4;
+
+        /**
+         * Event is based on a mix of the head, eyes, and hands. Ray origin is at average between
+         * eyes and points in direction based on a mix of eye gaze direction and hand motion. During
+         * a two-handed zoom/rotate gesture, left/right pointer events will be issued; otherwise,
+         * default events are issued based on the gaze ray. Action state is based on if the user has
+         * done a pinch gesture or not.
+         *
+         * <p>Events from this source are considered sensitive and hover events are never sent.
+         */
+        public static final int SOURCE_GAZE_AND_GESTURE = 5;
+
+        /**
+         * Default pointer type for the source (no handedness). Occurs for SOURCE_UNKNOWN,
+         * SOURCE_HEAD, SOURCE_MOUSE, and SOURCE_GAZE_AND_GESTURE.
+         */
+        public static final int POINTER_TYPE_DEFAULT = 0;
+
+        /**
+         * Left hand / controller pointer. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+         * SOURCE_GAZE_AND_GESTURE.
+         */
+        public static final int POINTER_TYPE_LEFT = 1;
+
+        /**
+         * Right hand / controller pointer. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+         * SOURCE_GAZE_AND_GESTURE.
+         */
+        public static final int POINTER_TYPE_RIGHT = 2;
+
+        /** The primary action button or gesture was just pressed / started. */
+        public static final int ACTION_DOWN = 0;
+
+        /**
+         * The primary action button or gesture was just released / stopped. The hit info represents
+         * the node that was originally hit (ie, as provided in the ACTION_DOWN event).
+         */
+        public static final int ACTION_UP = 1;
+
+        /**
+         * The primary action button or gesture was pressed/active in the previous event, and is
+         * still pressed/active. The hit info represents the node that was originally hit (ie, as
+         * provided in the ACTION_DOWN event). The hit position may be null if the pointer is no
+         * longer hitting that node.
+         */
+        public static final int ACTION_MOVE = 2;
+
+        /**
+         * While the primary action button or gesture was held, the pointer was disabled. This
+         * happens if you are using controllers and the battery runs out, or if you are using a
+         * source that transitions to a new pointer type, eg SOURCE_GAZE_AND_GESTURE.
+         */
+        public static final int ACTION_CANCEL = 3;
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray continued to hit
+         * the same node. The hit info represents the node that was hit (may be null if pointer
+         * capture is enabled).
+         *
+         * <p>Hover input events are never provided for sensitive source types.
+         */
+        public static final int ACTION_HOVER_MOVE = 4;
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray started to hit a
+         * new node. The hit info represents the node that is being hit (may be null if pointer
+         * capture is enabled).
+         *
+         * <p>Hover input events are never provided for sensitive source types.
+         */
+        public static final int ACTION_HOVER_ENTER = 5;
+
+        /**
+         * The primary action button or gesture is not pressed, and the pointer ray stopped hitting
+         * the node that it was previously hitting. The hit info represents the node that was being
+         * hit (may be null if pointer capture is enabled).
+         *
+         * <p>Hover input events are never provided for sensitive source types.
+         */
+        public static final int ACTION_HOVER_EXIT = 6;
+
+        @SuppressWarnings("MutableBareField")
+        @Source
+        public int source;
+
+        @SuppressWarnings("MutableBareField")
+        @PointerType
+        public int pointerType;
+
+        /** The time this event occurred, in the android.os.SystemClock#uptimeMillis time base. */
+        @SuppressWarnings({
+            "GoodTime",
+            "MutableBareField"
+        }) // This field mirrors the XR Extensions InputEvent.
+        public long timestamp;
+
+        /**
+         * The origin of the ray, in the receiver's activity space. Will be zero if the source is
+         * not ray-based (eg, direct touch).
+         */
+        @SuppressWarnings("MutableBareField")
+        @NonNull
+        public Vector3 origin;
+
+        /**
+         * A point indicating the direction the ray is pointing in, in the receiver's activity
+         * space. The ray is a vector starting at the origin point and passing through the direction
+         * point.
+         */
+        @SuppressWarnings("MutableBareField")
+        @NonNull
+        public Vector3 direction;
+
+        /** Info about the hit result of the ray. */
+        public static class HitInfo {
+            /**
+             * The entity that was hit by the input ray.
+             *
+             * <p>ACTION_MOVE, ACTION_UP, and ACTION_CANCEL events will report the same node as was
+             * hit during the initial ACTION_DOWN.
+             */
+            @Nullable public final Entity inputEntity;
+
+            /**
+             * The position of the hit in the receiver's activity space.
+             *
+             * <p>All events may report the current ray's hit position. This can be null if there no
+             * longer is a collision between the ray and the input node (eg, during a drag event).
+             */
+            @Nullable public final Vector3 hitPosition;
+
+            /**
+             * The matrix transforming activity space coordinates into the hit entity's local
+             * coordinate space.
+             */
+            @NonNull public final Matrix4 transform;
+
+            /**
+             * @param inputEntity the entity that was hit by the input ray.
+             * @param hitPosition the position of the hit in the receiver's activity space.
+             * @param transform the matrix transforming activity space coordinates into the hit
+             *     entity's local coordinate space.
+             */
+            public HitInfo(
+                    @Nullable Entity inputEntity,
+                    @Nullable Vector3 hitPosition,
+                    @NonNull Matrix4 transform) {
+                this.inputEntity = inputEntity;
+                this.hitPosition = hitPosition;
+                this.transform = transform;
+            }
+        }
+
+        /** Returns the current action associated with this input event. */
+        @SuppressWarnings("MutableBareField")
+        public int action;
+
+        /**
+         * Info on the first entity (closest to the ray origin) that was hit by the input ray, if
+         * any. This info will be null if no Entity was hit.
+         */
+        @SuppressWarnings("MutableBareField")
+        @Nullable
+        public HitInfo hitInfo;
+
+        /** Info on the second entity for the same task that was hit by the input ray, if any. */
+        @SuppressWarnings("MutableBareField")
+        @Nullable
+        public HitInfo secondaryHitInfo;
+
+        @SuppressWarnings("GoodTime")
+        public InputEvent(
+                @Source int source,
+                @PointerType int pointerType,
+                long timestamp,
+                @NonNull Vector3 origin,
+                @NonNull Vector3 direction,
+                @Action int action,
+                @Nullable HitInfo hitInfo,
+                @Nullable HitInfo secondaryHitInfo) {
+            this.source = source;
+            this.pointerType = pointerType;
+            this.timestamp = timestamp;
+            this.origin = origin;
+            this.direction = direction;
+            this.action = action;
+            this.hitInfo = hitInfo;
+            this.secondaryHitInfo = secondaryHitInfo;
+        }
+
+        /** Describes the hardware source of the event. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    SOURCE_UNKNOWN,
+                    SOURCE_HEAD,
+                    SOURCE_CONTROLLER,
+                    SOURCE_HANDS,
+                    SOURCE_MOUSE,
+                    SOURCE_GAZE_AND_GESTURE,
+                })
+        public @interface Source {}
+
+        /** The type of the individual pointer. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    POINTER_TYPE_DEFAULT, // Default for the source.
+                    POINTER_TYPE_LEFT, // Left hand/controller.
+                    POINTER_TYPE_RIGHT, // Right hand/controller.
+                })
+        public @interface PointerType {}
+
+        /**
+         * Actions similar to Android's MotionEvent actions: <a
+         * href="https://developer.android.com/reference/android/view/MotionEvent"></a> for keeping
+         * track of a sequence of events on the same target, e.g., * HOVER_ENTER -> HOVER_MOVE ->
+         * HOVER_EXIT * DOWN -> MOVE -> UP
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    ACTION_DOWN,
+                    ACTION_UP,
+                    ACTION_MOVE,
+                    ACTION_CANCEL,
+                    ACTION_HOVER_MOVE,
+                    ACTION_HOVER_ENTER,
+                    ACTION_HOVER_EXIT,
+                })
+        public @interface Action {}
+    }
+
+    /** Spatial Capabilities for SceneCore Platform. */
+    class SpatialCapabilities {
+
+        /** The activity can spatialize itself by e.g. adding a spatial panel. */
+        public static final int SPATIAL_CAPABILITY_UI = 1 << 0;
+
+        /** The activity can create 3D contents. */
+        public static final int SPATIAL_CAPABILITY_3D_CONTENT = 1 << 1;
+
+        /** The activity can enable or disable passthrough. */
+        public static final int SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL = 1 << 2;
+
+        /** The activity can set its own environment. */
+        public static final int SPATIAL_CAPABILITY_APP_ENVIRONMENT = 1 << 3;
+
+        /** The activity can use spatial audio. */
+        public static final int SPATIAL_CAPABILITY_SPATIAL_AUDIO = 1 << 4;
+
+        /** The activity can spatially embed another activity. */
+        public static final int SPATIAL_CAPABILITY_EMBED_ACTIVITY = 1 << 5;
+
+        /** Spatial Capabilities for SceneCore Platform. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                flag = true,
+                value = {
+                    SPATIAL_CAPABILITY_UI,
+                    SPATIAL_CAPABILITY_3D_CONTENT,
+                    SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL,
+                    SPATIAL_CAPABILITY_APP_ENVIRONMENT,
+                    SPATIAL_CAPABILITY_SPATIAL_AUDIO,
+                    SPATIAL_CAPABILITY_EMBED_ACTIVITY,
+                })
+        public @interface SpatialCapability {}
+
+        @SuppressWarnings("MutableBareField")
+        @SpatialCapability
+        public int capabilities;
+
+        public SpatialCapabilities(@SpatialCapability int capabilities) {
+            this.capabilities = capabilities;
+        }
+
+        public boolean hasCapability(@SpatialCapability int capability) {
+            return (capabilities & capability) != 0;
+        }
+    }
+
+    /** Interface for a SceneCore SoundPoolExtensionsWrapper. */
+    interface SoundPoolExtensionsWrapper {
+
+        int play(
+                @NonNull SoundPool soundPool,
+                int soundId,
+                @NonNull PointSourceAttributes attributes,
+                float volume,
+                int priority,
+                int loop,
+                float rate);
+
+        int play(
+                @NonNull SoundPool soundPool,
+                int soundId,
+                @NonNull SoundFieldAttributes attributes,
+                float volume,
+                int priority,
+                int loop,
+                float rate);
+
+        @SpatializerConstants.SourceType
+        int getSpatialSourceType(@NonNull SoundPool soundPool, int streamId);
+    }
+
+    /** Interface for a SceneCore AudioTrackExtensionsWrapper */
+    interface AudioTrackExtensionsWrapper {
+
+        @Nullable
+        PointSourceAttributes getPointSourceAttributes(@NonNull AudioTrack track);
+
+        @Nullable
+        SoundFieldAttributes getSoundFieldAttributes(@NonNull AudioTrack track);
+
+        int getSpatialSourceType(@NonNull AudioTrack track);
+
+        @NonNull
+        AudioTrack.Builder setPointSourceAttributes(
+                @NonNull AudioTrack.Builder builder, @NonNull PointSourceAttributes attributes);
+
+        @NonNull
+        AudioTrack.Builder setSoundFieldAttributes(
+                @NonNull AudioTrack.Builder builder, @NonNull SoundFieldAttributes attributes);
+    }
+
+    /** Interface for a SceneCore MediaPlayerExtensionsWrapper */
+    interface MediaPlayerExtensionsWrapper {
+
+        void setPointSourceAttributes(
+                @NonNull MediaPlayer mediaPlayer, @NonNull PointSourceAttributes attributes);
+
+        void setSoundFieldAttributes(
+                @NonNull MediaPlayer mediaPlayer, @NonNull SoundFieldAttributes attributes);
+    }
+
+    /** Represents a SceneCore PointSourceAttributes */
+    class PointSourceAttributes {
+        private final Entity entity;
+
+        public PointSourceAttributes(@NonNull Entity entity) {
+            this.entity = entity;
+        }
+
+        /** Gets the SceneCore {@link Entity} for this instance. */
+        @NonNull
+        public Entity getEntity() {
+            return this.entity;
+        }
+    }
+
+    /** Represents a SceneCore SoundFieldAttributes */
+    class SoundFieldAttributes {
+
+        @SpatializerConstants.AmbisonicsOrder private final int ambisonicsOrder;
+
+        public SoundFieldAttributes(int ambisonicsOrder) {
+            this.ambisonicsOrder = ambisonicsOrder;
+        }
+
+        public int getAmbisonicsOrder() {
+            return ambisonicsOrder;
+        }
+    }
+
+    /** Contains the constants used to spatialize audio in SceneCore. */
+    final class SpatializerConstants {
+
+        private SpatializerConstants() {}
+
+        /** Used to set the Ambisonics order of a [SoundFieldAttributes]. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    AMBISONICS_ORDER_FIRST_ORDER,
+                    AMBISONICS_ORDER_SECOND_ORDER,
+                    AMBISONICS_ORDER_THIRD_ORDER,
+                })
+        public @interface AmbisonicsOrder {}
+
+        /** Specifies spatial rendering using First Order Ambisonics */
+        public static final int AMBISONICS_ORDER_FIRST_ORDER = 0;
+
+        /** Specifies spatial rendering using Second Order Ambisonics */
+        public static final int AMBISONICS_ORDER_SECOND_ORDER = 1;
+
+        /** Specifies spatial rendering using Third Order Ambisonics */
+        public static final int AMBISONICS_ORDER_THIRD_ORDER = 2;
+
+        /** Represents the type of spatialization for an audio source. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(
+                value = {
+                    SOURCE_TYPE_BYPASS,
+                    SOURCE_TYPE_POINT_SOURCE,
+                    SOURCE_TYPE_SOUND_FIELD,
+                })
+        public @interface SourceType {}
+
+        /** The sound source has not been spatialized with the Spatial Audio SDK. */
+        public static final int SOURCE_TYPE_BYPASS = 0;
+
+        /** The sound source has been spatialized as a 3D point source. */
+        public static final int SOURCE_TYPE_POINT_SOURCE = 1;
+
+        /** The sound source is an ambisonics sound field. */
+        public static final int SOURCE_TYPE_SOUND_FIELD = 2;
+    }
+
+    /** Returns a [SoundPoolExtensionsWrapper] instance. */
+    @NonNull
+    public SoundPoolExtensionsWrapper getSoundPoolExtensionsWrapper();
+
+    /** Returns an [AudioTrackExtensionssWrapper] instance. */
+    @NonNull
+    public AudioTrackExtensionsWrapper getAudioTrackExtensionsWrapper();
+
+    /** Returns a [MediaPlayerExtensionsWrapper] instance. */
+    @NonNull
+    public MediaPlayerExtensionsWrapper getMediaPlayerExtensionsWrapper();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Model.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Model.kt
new file mode 100644
index 0000000..3d6459e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Model.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+import androidx.concurrent.futures.ResolvableFuture
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource as RtGltfModel
+import com.google.common.util.concurrent.ListenableFuture
+
+/** Represents a 3D model in SceneCore. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface Model
+
+/**
+ * [GltfModel] represents a glTF resource in SceneCore. These can be used as part of the
+ * [Environment] or to display 3D models with [GltfModelEntity]. These are created by the [Session].
+ */
+// TODO: b/319269278 - Make this and ExrImage derive from a common Resource base class which has
+//                     async helpers.
+// TODO: b/362368652 - Add an interface which returns an integer animation IDX given a string
+//                     animation name for a loaded glTF, as well as an interface for selecting the
+//                     playback animation from the integer index.
+// TODO: b/362368652 - Add an interface which returns a list of available animation names
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class GltfModel internal constructor(public val model: RtGltfModel) : Model {
+
+    internal companion object {
+        @Deprecated(
+            message = "This function is deprecated, use createAsync() instead",
+            replaceWith = ReplaceWith("createAsync()"),
+        )
+        internal fun create(runtime: JxrPlatformAdapter, name: String): GltfModel {
+            val gltfResourceFuture = runtime.loadGltfByAssetName(name)
+            // TODO: b/320858652 - Implement async loading of GltfModel.
+            return GltfModel(gltfResourceFuture!!.get())
+        }
+
+        // ResolvableFuture is marked as RestrictTo(LIBRARY_GROUP_PREFIX), which is intended for
+        // classes
+        // within AndroidX. We're in the process of migrating to AndroidX. Without suppressing this
+        // warning, however, we get a build error - go/bugpattern/RestrictTo.
+        @SuppressWarnings("RestrictTo")
+        internal fun createAsync(
+            runtime: JxrPlatformAdapter,
+            name: String,
+        ): ListenableFuture<GltfModel> {
+            val gltfResourceFuture = runtime.loadGltfByAssetNameSplitEngine(name)
+            val modelFuture = ResolvableFuture.create<GltfModel>()
+
+            // TODO: b/375070346 - remove this `!!` when we're sure the future is non-null.
+            gltfResourceFuture!!.addListener(
+                {
+                    try {
+                        val gltfResource = gltfResourceFuture.get()
+                        modelFuture.set(GltfModel(gltfResource))
+                    } catch (e: Exception) {
+                        if (e is InterruptedException) {
+                            Thread.currentThread().interrupt()
+                        }
+                        modelFuture.setException(e)
+                    }
+                },
+                Runnable::run,
+            )
+            return modelFuture
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as GltfModel
+
+        // Perform a structural equality check on the underlying model.
+        if (model != other.model) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        return model.hashCode()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MovableComponent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MovableComponent.kt
new file mode 100644
index 0000000..9e3bde9
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MovableComponent.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("BanConcurrentHashMap")
+
+package androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+
+/**
+ * Allows users to interactively move the Entity. This component can be attached to a single
+ * instance of any PanelEntity.
+ *
+ * NOTE: This Component is currently unsupported on GltfModelEntity.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class MovableComponent
+private constructor(
+    private val runtime: JxrPlatformAdapter,
+    private val entityManager: EntityManager,
+    private val systemMovable: Boolean = true,
+    private val scaleInZ: Boolean = true,
+    private val anchorPlacement: Set<AnchorPlacement> = emptySet(),
+    private val shouldDisposeParentAnchor: Boolean = true,
+) : Component {
+    private val rtMovableComponent by lazy {
+        runtime.createMovableComponent(
+            systemMovable,
+            scaleInZ,
+            anchorPlacement.toRtAnchorPlacement(runtime),
+            shouldDisposeParentAnchor,
+        )
+    }
+    private val moveListenersMap =
+        ConcurrentHashMap<MoveListener, JxrPlatformAdapter.MoveEventListener>()
+
+    private var entity: Entity? = null
+
+    /**
+     * The current size of the entity, in meters. The size of the entity determines the size of the
+     * bounding box that is used to draw the draggable move affordances around the entity.
+     */
+    public var size: Dimensions = kDimensionsOneMeter
+        set(value) {
+            if (field != value) {
+                field = value
+                rtMovableComponent.setSize(value.toRtDimensions())
+            }
+        }
+
+    override fun onAttach(entity: Entity): Boolean {
+        if (this.entity != null) {
+            Log.e("MovableComponent", "Already attached to entity ${this.entity}")
+            return false
+        }
+        this.entity = entity
+        return (entity as BaseEntity<*>).rtEntity.addComponent(rtMovableComponent)
+    }
+
+    override fun onDetach(entity: Entity) {
+        (entity as BaseEntity<*>).rtEntity.removeComponent(rtMovableComponent)
+        this.entity = null
+    }
+
+    /**
+     * Adds a listener to the set of active listeners for the move events. The listener will be
+     * invoked regardless of whether the entity is being moved by the system or the user.
+     *
+     * <p>The listener is invoked on the provided executor. If the app intends to modify the UI
+     * elements/views during the callback, the app should provide the thread executor that is
+     * appropriate for the UI operations. For example, if the app is using the main thread to render
+     * the UI, the app should provide the main thread (Looper.getMainLooper()) executor. If the app
+     * is using a separate thread to render the UI, the app should provide the executor for that
+     * thread.
+     *
+     * @param executor The executor to run the listener on.
+     * @param moveListener The move event listener to set.
+     */
+    public fun addMoveListener(executor: Executor, moveListener: MoveListener) {
+        val rtMoveEventListener =
+            JxrPlatformAdapter.MoveEventListener { rtMoveEvent ->
+                run {
+                    // TODO: b/369157703 - Mirror the callback hierarchy in the runtime API.
+                    val moveEvent = rtMoveEvent.toMoveEvent(entityManager)
+                    when (moveEvent.moveState) {
+                        MoveEvent.MOVE_STATE_START ->
+                            entity?.let {
+                                moveListener.onMoveStart(
+                                    it,
+                                    moveEvent.initialInputRay,
+                                    moveEvent.previousPose,
+                                    moveEvent.previousScale,
+                                    moveEvent.initialParent,
+                                )
+                            }
+                        MoveEvent.MOVE_STATE_ONGOING ->
+                            entity?.let {
+                                moveListener.onMoveUpdate(
+                                    it,
+                                    moveEvent.currentInputRay,
+                                    moveEvent.currentPose,
+                                    moveEvent.currentScale,
+                                )
+                            }
+                        MoveEvent.MOVE_STATE_END ->
+                            entity?.let {
+                                moveListener.onMoveEnd(
+                                    it,
+                                    moveEvent.currentInputRay,
+                                    moveEvent.currentPose,
+                                    moveEvent.currentScale,
+                                    moveEvent.updatedParent,
+                                )
+                            }
+                    }
+                }
+            }
+        rtMovableComponent.addMoveEventListener(executor, rtMoveEventListener)
+        moveListenersMap[moveListener] = rtMoveEventListener
+    }
+
+    /**
+     * Removes a listener from the set of active listeners for the move events.
+     *
+     * @param moveListener The move event listener to remove.
+     */
+    public fun removeMoveListener(moveListener: MoveListener) {
+        val rtMoveEventListener = moveListenersMap.remove(moveListener)
+        if (rtMoveEventListener != null) {
+            rtMovableComponent.removeMoveEventListener(rtMoveEventListener)
+        }
+    }
+
+    internal companion object {
+        private val kDimensionsOneMeter = Dimensions(1f, 1f, 1f)
+
+        /** Factory function for creating a MovableComponent. */
+        internal fun create(
+            runtime: JxrPlatformAdapter,
+            entityManager: EntityManager,
+            systemMovable: Boolean = true,
+            scaleInZ: Boolean = true,
+            anchorPlacement: Set<AnchorPlacement> = emptySet(),
+            shouldDisposeParentAnchor: Boolean = true,
+        ): MovableComponent {
+            return MovableComponent(
+                runtime,
+                entityManager,
+                systemMovable,
+                scaleInZ,
+                anchorPlacement,
+                shouldDisposeParentAnchor,
+            )
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MoveEvent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MoveEvent.kt
new file mode 100644
index 0000000..7f5002e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/MoveEvent.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Ray
+
+/**
+ * A high-level move event which is sent in response to the User interacting with the Entity.
+ *
+ * @param moveState State of the move action i.e. move started, ongoing or ended.
+ * @param initialInputRay Ray for the user's input at initial update.
+ * @param currentInputRay Ray for the user's input at the new update.
+ * @param previousPose Pose before this event, relative to its parent.
+ * @param currentPose Pose when this event is applied, relative to its parent.
+ * @param previousScale Scale before this event.
+ * @param currentScale Scale when this event is applied.
+ */
+internal class MoveEvent(
+    @MoveState public val moveState: Int,
+    public val initialInputRay: Ray,
+    public val currentInputRay: Ray,
+    public val previousPose: Pose,
+    public val currentPose: Pose,
+    public val previousScale: Float,
+    public val currentScale: Float,
+    public val initialParent: Entity,
+    public val updatedParent: Entity?,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is MoveEvent) return false
+
+        if (moveState != other.moveState) return false
+        if (initialInputRay != other.initialInputRay) return false
+        if (currentInputRay != other.currentInputRay) return false
+        if (previousPose != other.previousPose) return false
+        if (currentPose != other.currentPose) return false
+        if (previousScale != other.previousScale) return false
+        if (currentScale != other.currentScale) return false
+        if (initialParent != other.initialParent) return false
+        if (updatedParent != other.updatedParent) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = moveState.hashCode()
+        result = 31 * result + initialInputRay.hashCode()
+        result = 31 * result + currentInputRay.hashCode()
+        result = 31 * result + previousPose.hashCode()
+        result = 31 * result + currentPose.hashCode()
+        result = 31 * result + previousScale.hashCode()
+        result = 31 * result + currentScale.hashCode()
+        result = 31 * result + initialParent.hashCode()
+        if (updatedParent != null) {
+            result = 31 * result + updatedParent.hashCode()
+        }
+        return result
+    }
+
+    public companion object {
+        public const val MOVE_STATE_START: Int = 1
+        public const val MOVE_STATE_ONGOING: Int = 2
+        public const val MOVE_STATE_END: Int = 3
+    }
+
+    @IntDef(value = [MOVE_STATE_START, MOVE_STATE_ONGOING, MOVE_STATE_END])
+    public annotation class MoveState
+}
+
+/** Listener for move actions. Callbacks are invoked as user interacts with the entity. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface MoveListener {
+    /**
+     * Called when the user starts moving the entity.
+     *
+     * @param entity The entity being moved.
+     * @param initialInputRay Ray for the user's input at initial update.
+     * @param initialPose Initial Pose of the entity relative to its parent.
+     * @param initialScale Initial scale of the entity.
+     * @param initialParent Initial parent of the entity.
+     */
+    public fun onMoveStart(
+        entity: Entity,
+        initialInputRay: Ray,
+        initialPose: Pose,
+        initialScale: Float,
+        initialParent: Entity,
+    ) {}
+
+    /**
+     * Called continuously while the user is moving the entity.
+     *
+     * @param entity The entity being moved.
+     * @param currentInputRay Ray for the user's input at the new update.
+     * @param currentPose Pose of the entity during this event relative to its parent.
+     * @param currentScale Scale of the entity during this event.
+     */
+    public fun onMoveUpdate(
+        entity: Entity,
+        currentInputRay: Ray,
+        currentPose: Pose,
+        currentScale: Float,
+    ) {}
+
+    /**
+     * Called when the user has finished moving the entity.
+     *
+     * @param entity The entity being moved.
+     * @param finalInputRay Ray for the user's input at the final update.
+     * @param finalPose Pose of the entity during this event relative to its parent.
+     * @param finalScale Scale of the entity during this event.
+     * @param updatedParent If anchorPlacement is set, the entity may have a new parent when the
+     *   movement completes. This will be a new AnchorEntity, if it was anchored or re-anchored
+     *   during the movement, or the activity space, if it was unanchored. This will be null if
+     *   there was no updated parent on the entity.
+     */
+    public fun onMoveEnd(
+        entity: Entity,
+        finalInputRay: Ray,
+        finalPose: Pose,
+        finalScale: Float,
+        updatedParent: Entity?,
+    ) {}
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PermissionHelper.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PermissionHelper.kt
new file mode 100644
index 0000000..cde0f02
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PermissionHelper.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.annotation.RestrictTo
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+
+/**
+ * Utility class for handling Android permissions. SceneCore applications should use this before
+ * creating Anchors.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object PermissionHelper {
+    public const val SCENE_UNDERSTANDING_PERMISSION_CODE: Int = 0
+    public const val SCENE_UNDERSTANDING_PERMISSION: String =
+        "android.permission.SCENE_UNDERSTANDING"
+
+    public fun hasPermission(activity: Activity, permission: String): Boolean =
+        ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
+
+    public fun requestPermission(
+        activity: Activity,
+        permission: String,
+        permissionCode: Int
+    ): Unit = ActivityCompat.requestPermissions(activity, arrayOf(permission), permissionCode)
+
+    public fun shouldShowRequestPermissionRationale(
+        activity: Activity,
+        permission: String
+    ): Boolean = ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
+
+    public fun launchPermissionSettings(activity: Activity) {
+        val intent = Intent()
+        intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+        intent.setData(Uri.fromParts("package", activity.packageName, null))
+        activity.startActivity(intent)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointSourceAttributes.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointSourceAttributes.kt
new file mode 100644
index 0000000..7095c66
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointSourceAttributes.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+
+/**
+ * PointSourceAttributes is used to configure a sound be spatialized as a 3D point.
+ *
+ * If the audio being played is stereo or multichannel AND the AudioAttributes USAGE_TYPE is
+ * USAGE_MEDIA then the point provided will serve as the focal point of the media sound bed.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PointSourceAttributes(public val entity: Entity) {
+
+    internal val rtPointSourceAttributes: JxrPlatformAdapter.PointSourceAttributes =
+        JxrPlatformAdapter.PointSourceAttributes((entity as BaseEntity<*>).rtEntity)
+}
+
+internal fun JxrPlatformAdapter.PointSourceAttributes.toPointSourceAttributes(
+    session: Session
+): PointSourceAttributes? {
+    val jxrEntity = session.getEntityForRtEntity(entity)
+    return jxrEntity?.let { PointSourceAttributes(it) }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointerCaptureComponent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointerCaptureComponent.kt
new file mode 100644
index 0000000..48b1150
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/PointerCaptureComponent.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+import java.util.concurrent.Executor
+
+/**
+ * Provides pointer capture capabilities for a given entity.
+ *
+ * To enable pointer capture, the task must be in full space, and the entity must be visible.
+ *
+ * Only one PointerCaptureComponent can be attached to an entity at a given time. If a second one
+ * tries to attach to an entity, it will fail.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PointerCaptureComponent
+private constructor(
+    private val runtime: JxrPlatformAdapter,
+    private val entityManager: EntityManager,
+    private val executor: Executor,
+    private val stateListener: StateListener,
+    private val inputEventListener: InputEventListener,
+) : Component {
+
+    private var attachedEntity: Entity? = null
+
+    private val rtInputEventListener =
+        JxrPlatformAdapter.InputEventListener { rtEvent ->
+            inputEventListener.onInputEvent(rtEvent.toInputEvent(entityManager))
+        }
+
+    private val rtStateListener =
+        JxrPlatformAdapter.PointerCaptureComponent.StateListener { pcState: Int ->
+            when (pcState) {
+                JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_PAUSED ->
+                    stateListener.onStateChanged(POINTER_CAPTURE_STATE_PAUSED)
+                JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_ACTIVE ->
+                    stateListener.onStateChanged(POINTER_CAPTURE_STATE_ACTIVE)
+                JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_STOPPED ->
+                    stateListener.onStateChanged(POINTER_CAPTURE_STATE_STOPPED)
+                else -> {
+                    Log.e(TAG, "Unknown pointer capture state received: ${pcState}")
+                    stateListener.onStateChanged(pcState)
+                }
+            }
+        }
+
+    private val rtComponent by lazy {
+        runtime.createPointerCaptureComponent(executor, rtStateListener, rtInputEventListener)
+    }
+
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        value =
+            [
+                POINTER_CAPTURE_STATE_PAUSED,
+                POINTER_CAPTURE_STATE_ACTIVE,
+                POINTER_CAPTURE_STATE_STOPPED
+            ]
+    )
+    internal annotation class PointerCaptureState
+
+    /** Listener for pointer capture state changes. */
+    public interface StateListener {
+        public fun onStateChanged(@PointerCaptureState newState: Int)
+    }
+
+    override fun onAttach(entity: Entity): Boolean {
+        if (attachedEntity != null) {
+            Log.e(TAG, "Already attached to entity ${attachedEntity}")
+            return false
+        }
+        attachedEntity = entity
+
+        return (entity as BaseEntity<*>).rtEntity.addComponent(rtComponent)
+    }
+
+    override fun onDetach(entity: Entity) {
+        if (entity != attachedEntity) {
+            Log.e(TAG, "Detaching from non-attached entity, ignoring")
+            return
+        }
+        (entity as BaseEntity<*>).rtEntity.removeComponent(rtComponent)
+        attachedEntity = null
+    }
+
+    public companion object {
+        /** Pointer Capture is enabled for this component. */
+        public const val POINTER_CAPTURE_STATE_PAUSED: Int = 0
+
+        /** Pointer Capture is disabled for this component. */
+        public const val POINTER_CAPTURE_STATE_ACTIVE: Int = 1
+
+        /** Pointer Capture has been stopped for this component. */
+        public const val POINTER_CAPTURE_STATE_STOPPED: Int = 2
+
+        private const val TAG: String = "PointerCaptureComponent"
+
+        /** Factory function for creating [PointerCaptureComponent] instances. */
+        @JvmStatic
+        public fun create(
+            session: Session,
+            executor: Executor,
+            stateListener: StateListener,
+            inputListener: InputEventListener,
+        ): PointerCaptureComponent =
+            PointerCaptureComponent(
+                session.runtime,
+                session.entityManager,
+                executor,
+                stateListener,
+                inputListener,
+            )
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizableComponent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizableComponent.kt
new file mode 100644
index 0000000..def8015
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizableComponent.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("BanConcurrentHashMap")
+
+package androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+
+/**
+ * A [Component] which when attached to a [PanelEntity] provides a user-resize affordance.
+ *
+ * Note: This Component is currently unsupported on GltfModelEntity.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ResizableComponent
+private constructor(
+    private val runtime: JxrPlatformAdapter,
+    minimumSize: Dimensions,
+    maximumSize: Dimensions,
+) : Component {
+    private val resizeListenerMap =
+        ConcurrentHashMap<ResizeListener, JxrPlatformAdapter.ResizeEventListener>()
+    /**
+     * The current size of the entity, in meters. This property is automatically updated after
+     * resize events to match the resize affordance to the newly suggested size of the content. The
+     * apps can still override it. The default value is set to 1 meter, updated to the size of the
+     * entity when attached.
+     */
+    public var size: Dimensions = kDimensionsOneMeter
+        set(value) {
+            if (field != value) {
+                field = value
+                rtResizableComponent.setSize(value.toRtDimensions())
+            }
+        }
+
+    /**
+     * A lower bound for the User's resize actions, in meters. This value constrains how small the
+     * user can resize the bounding box of the entity. The size of the content inside that bounding
+     * box is fully controlled by the application.
+     */
+    public var minimumSize: Dimensions = minimumSize
+        set(value) {
+            if (field != value) {
+                field = value
+                rtResizableComponent.setMinimumSize(value.toRtDimensions())
+            }
+        }
+
+    /**
+     * An upper bound for the User's resize actions, in meters. This value constrains large the user
+     * can resize the bounding box of the entity. The size of the content inside that bounding box
+     * is fully controlled by the application.
+     */
+    public var maximumSize: Dimensions = maximumSize
+        set(value) {
+            if (field != value) {
+                field = value
+                rtResizableComponent.setMaximumSize(value.toRtDimensions())
+            }
+        }
+
+    /**
+     * The aspect ratio of the entity during resizing. The aspect ratio is determined by taking the
+     * entity's width over its height. A value of 0.0f (or negative) means there are no preferences.
+     *
+     * This method does not immediately resize the entity. The new aspect ratio will be applied the
+     * next time the user resizes the entity through the reform UI. During this resize operation,
+     * the entity's current area will be preserved.
+     *
+     * If a different resizing behavior is desired, such as fixing the width and adjusting the
+     * height, the client can manually resize the entity to the preferred dimensions before calling
+     * this method. No automatic resizing will occur when using the reform UI then.
+     */
+    public var fixedAspectRatio: Float = 0.0f
+        set(value) {
+            if (field != value) {
+                field = value
+                rtResizableComponent.setFixedAspectRatio(value)
+            }
+        }
+
+    private val rtResizableComponent by lazy {
+        runtime.createResizableComponent(minimumSize.toRtDimensions(), maximumSize.toRtDimensions())
+    }
+
+    private var entity: Entity? = null
+
+    /**
+     * Attaches this component to the given entity.
+     *
+     * @param entity The entity to attach this component to.
+     * @return `true` if the component was successfully attached, `false` otherwise.
+     */
+    override fun onAttach(entity: Entity): Boolean {
+        if (this.entity != null) {
+            Log.e("MovableComponent", "Already attached to entity ${this.entity}")
+            return false
+        }
+        this.entity = entity
+        return (entity as BaseEntity<*>).rtEntity.addComponent(rtResizableComponent)
+    }
+
+    /**
+     * Detaches this component from the entity it is attached to.
+     *
+     * @param entity The entity to detach this component from.
+     */
+    override fun onDetach(entity: Entity) {
+        (entity as BaseEntity<*>).rtEntity.removeComponent(rtResizableComponent)
+        this.entity = null
+    }
+
+    /**
+     * Adds the listener to the set of listeners that are invoked through the resize operation, such
+     * as start, ongoing and end.
+     *
+     * The listener is invoked on the provided executor. If the app intends to modify the UI
+     * elements/views during the callback, the app should provide the thread executor that is
+     * appropriate for the UI operations. For example, if the app is using the main thread to render
+     * the UI, the app should provide the main thread (Looper.getMainLooper()) executor. If the app
+     * is using a separate thread to render the UI, the app should provide the executor for that
+     * thread.
+     *
+     * @param executor The executor to use for the listener callback.
+     * @param resizeListener The listener to be invoked when a resize event occurs.
+     */
+    public fun addResizeListener(executor: Executor, resizeListener: ResizeListener) {
+        val rtResizeEventListener =
+            JxrPlatformAdapter.ResizeEventListener { rtResizeEvent ->
+                run {
+                    val resizeEvent = rtResizeEvent.toResizeEvent()
+                    when (resizeEvent.resizeState) {
+                        ResizeEvent.RESIZE_STATE_ONGOING ->
+                            entity?.let { resizeListener.onResizeUpdate(it, resizeEvent.newSize) }
+                        ResizeEvent.RESIZE_STATE_END ->
+                            entity?.let { resizeListener.onResizeEnd(it, resizeEvent.newSize) }
+                        ResizeEvent.RESIZE_STATE_START ->
+                            entity?.let { resizeListener.onResizeStart(it, size) }
+                    }
+                }
+            }
+        rtResizableComponent.addResizeEventListener(executor, rtResizeEventListener)
+        resizeListenerMap[resizeListener] = rtResizeEventListener
+    }
+
+    /**
+     * Removes a listener from the set listening to resize events.
+     *
+     * @param resizeListener The listener to be removed.
+     */
+    public fun removeResizeListener(resizeListener: ResizeListener) {
+        if (resizeListenerMap.containsKey(resizeListener)) {
+            rtResizableComponent.removeResizeEventListener(resizeListenerMap[resizeListener]!!)
+            resizeListenerMap.remove(resizeListener)
+        }
+    }
+
+    internal companion object {
+        private val kDimensionsOneMeter = Dimensions(1f, 1f, 1f)
+        /** Defaults min and max sizes in meters. */
+        internal val kMinimumSize: Dimensions = Dimensions(0f, 0f, 0f)
+        internal val kMaximumSize: Dimensions = Dimensions(10f, 10f, 10f)
+
+        /** Factory function for creating [ResizableComponent] instance. */
+        internal fun create(
+            runtime: JxrPlatformAdapter,
+            minimumSize: Dimensions = kMinimumSize,
+            maximumSize: Dimensions = kMaximumSize,
+        ): ResizableComponent {
+            return ResizableComponent(runtime, minimumSize, maximumSize)
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizeEvent.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizeEvent.kt
new file mode 100644
index 0000000..2bc2c94
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/ResizeEvent.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+
+/**
+ * A high-level resize event which is sent in response to the User interacting with the Entity.
+ *
+ * @param resizeState The state of the resize event.
+ * @param newSize The new proposed size of the Entity in meters.
+ */
+internal data class ResizeEvent(
+    @ResizeState public val resizeState: Int,
+    public val newSize: Dimensions,
+) {
+    public companion object {
+        /** Constant for {@link resizeState}: The resize state is unknown. */
+        public const val RESIZE_STATE_UNKNOWN: Int = 0
+        /** Constant for {@link resizeState}: The user has started dragging the resize handles. */
+        public const val RESIZE_STATE_START: Int = 1
+        /** Constant for {@link resizeState}: The user is continuing to drag the resize handles. */
+        public const val RESIZE_STATE_ONGOING: Int = 2
+        /** Constant for {@link resizeState}: The user has stopped dragging the resize handles. */
+        public const val RESIZE_STATE_END: Int = 3
+    }
+
+    @IntDef(
+        value = [RESIZE_STATE_UNKNOWN, RESIZE_STATE_START, RESIZE_STATE_ONGOING, RESIZE_STATE_END]
+    )
+    public annotation class ResizeState
+}
+
+/**
+ * Listener for resize actions. Callbacks are invoked as the user interacts with the resize
+ * affordance.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface ResizeListener {
+    /**
+     * Called when the user starts resizing the entity.
+     *
+     * @param entity The entity being resized.
+     * @param originalSize The original size of the entity in meters at the start of the resize
+     *   operation.
+     */
+    public fun onResizeStart(entity: Entity, originalSize: Dimensions) {}
+
+    /**
+     * Called continuously while the user is resizing the entity.
+     *
+     * @param entity The entity being resized.
+     * @param newSize The new proposed size of the entity in meters.
+     */
+    public fun onResizeUpdate(entity: Entity, newSize: Dimensions) {}
+
+    /**
+     * Called when the user has finished resizing the entity, for example when the user concludes
+     * the resize gesture.
+     *
+     * @param entity The entity being resized.
+     * @param finalSize The final proposed size of the entity in meters.
+     */
+    public fun onResizeEnd(entity: Entity, finalSize: Dimensions) {}
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Session.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Session.kt
new file mode 100644
index 0000000..9c77976
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Session.kt
@@ -0,0 +1,623 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("BanConcurrentHashMap", "Deprecation")
+
+package androidx.xr.scenecore
+
+import android.app.Activity
+import android.app.Application.ActivityLifecycleCallbacks
+import android.graphics.Rect
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.annotation.MainThread
+import androidx.annotation.RestrictTo
+import androidx.xr.arcore.Anchor
+import androidx.xr.runtime.math.Pose
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity as RtEntity
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities as RtSpatialCapabilities
+import androidx.xr.scenecore.SpatialCapabilities.SpatialCapability
+import androidx.xr.scenecore.impl.JxrPlatformAdapterAxr
+import com.google.common.util.concurrent.ListenableFuture
+import java.time.Duration
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentMap
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.function.Consumer
+
+/**
+ * The Session provides the primary interface to SceneCore functionality for the application. Each
+ * spatialized Activity must create and hold an instance of Session.
+ *
+ * Once created, the application can use the Session interfaces to create spatialized entities, such
+ * as Widget panels and geometric models, set the background environment, and anchor content to the
+ * real world.
+ */
+// TODO: Make this class thread safe.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Session(
+    public val activity: Activity,
+    public val runtime: JxrPlatformAdapter,
+    public val spatialEnvironment: SpatialEnvironment,
+) {
+    internal val entityManager by lazy { EntityManager() }
+
+    private val spatialCapabilitiesListeners:
+        ConcurrentMap<Consumer<SpatialCapabilities>, Consumer<RtSpatialCapabilities>> =
+        ConcurrentHashMap()
+
+    /**
+     * The ActivitySpace is a special entity that represents the space in which the application is
+     * launched. It is the default parent of all entities in the scene.
+     *
+     * The ActivitySpace is created automatically when the Session is created.
+     */
+    public val activitySpace: ActivitySpace = ActivitySpace.create(runtime, entityManager)
+
+    /** The SpatialUser contains information about the user. */
+    public val spatialUser: SpatialUser = SpatialUser.create(runtime)
+
+    /**
+     * A spatialized PanelEntity associated with the "main window" for the Activity. When in
+     * HomeSpace mode, this is the application's "main window".
+     *
+     * If called multiple times, this will return the same PanelEntity.
+     */
+    public val mainPanelEntity: PanelEntity =
+        PanelEntity.createMainPanelEntity(runtime, entityManager)
+
+    /**
+     * The PerceptionSpace represents the origin of the space in which the ARCore for XR API
+     * provides tracking info. The transformations provided by the PerceptionSpace are only valid
+     * for the call frame, as the transformation can be changed by the system at any time.
+     */
+    public val perceptionSpace: PerceptionSpace = PerceptionSpace.create(runtime)
+
+    // TODO: 378706624 - Remove this method once we have a better way to handle the root entity.
+    public val activitySpaceRoot: Entity by lazy {
+        entityManager.getEntityForRtEntity(runtime.activitySpaceRootImpl)!!
+    }
+
+    public companion object {
+        private const val TAG = "Session"
+        private val activitySessionMap = ConcurrentHashMap<Activity, Session>()
+
+        // TODO: b/323060217 - Move the platformAdapter behind a loader class that loads it in.
+        /**
+         * Creates a session and pairs it with an Activity and its lifecycle. If a session is
+         * already paired with an Activity, return that Session instead of creating a new one.
+         *
+         * For our Alpha release, we just directly instantiate the Android XR PlatformAdapter.
+         */
+        // TODO(b/326748782): Change the returned Session here to be nullable or asynchronous.
+        // TODO: b/372299691 - Rename the runtime parameter to platformAdapter.
+        @JvmStatic
+        @JvmOverloads
+        public fun create(activity: Activity, runtime: JxrPlatformAdapter? = null): Session {
+            // TODO(bhavsar): Rethink moving this check when integration with Spatial Activity
+            // happens.
+            if (
+                !PermissionHelper.hasPermission(
+                    activity,
+                    PermissionHelper.SCENE_UNDERSTANDING_PERMISSION
+                )
+            ) {
+                PermissionHelper.requestPermission(
+                    activity,
+                    PermissionHelper.SCENE_UNDERSTANDING_PERMISSION,
+                    PermissionHelper.SCENE_UNDERSTANDING_PERMISSION_CODE,
+                )
+            }
+            return activitySessionMap.computeIfAbsent(activity) {
+                Log.i(TAG, "Creating session for activity $activity")
+                val session =
+                    when (runtime) {
+                        null -> {
+                            val runtimeImpl =
+                                JxrPlatformAdapterAxr.create(
+                                    activity,
+                                    Executors.newSingleThreadScheduledExecutor(
+                                        object : ThreadFactory {
+                                            override fun newThread(r: Runnable): Thread {
+                                                return Thread(r, "JXRCoreSession")
+                                            }
+                                        }
+                                    ),
+                                )
+                            Session(activity, runtimeImpl, SpatialEnvironment(runtimeImpl))
+                        }
+                        else -> Session(activity, runtime, SpatialEnvironment(runtime))
+                    }
+                activity.registerActivityLifecycleCallbacks(
+                    object : ActivityLifecycleCallbacks {
+                        override fun onActivityCreated(
+                            activity: Activity,
+                            savedInstanceState: Bundle?
+                        ) {}
+
+                        override fun onActivityStarted(activity: Activity) {}
+
+                        override fun onActivityResumed(activity: Activity) {
+                            session.runtime.startRenderer()
+                        }
+
+                        override fun onActivityPaused(activity: Activity) {
+                            session.runtime.stopRenderer()
+                        }
+
+                        override fun onActivityStopped(activity: Activity) {}
+
+                        override fun onActivitySaveInstanceState(
+                            activity: Activity,
+                            outState: Bundle
+                        ) {}
+
+                        override fun onActivityDestroyed(activity: Activity) {
+                            activitySessionMap.remove(activity)
+                            session.entityManager.clear()
+                            session.runtime.dispose()
+                        }
+                    }
+                )
+                session
+            }
+        }
+    }
+
+    /**
+     * Returns true if the Session is currently capable of the given [SpatialCapability], false
+     * otherwise. The available set of capabilities can change within a session.
+     */
+    @Deprecated(message = "Removing in favor of getSpatialCapabilities().hasCapability()")
+    // TODO: b/366214441 - Remove this method
+    public fun hasSpatialCapability(@SpatialCapability capability: Int): Boolean =
+        getSpatialCapabilities().hasCapability(capability)
+
+    /**
+     * Returns the current [SpatialCapabilities] of the Session. The set of capabilities can change
+     * within a session. The returned object will not update if the capabilities change; this method
+     * should be called again to get the latest set of capabilities.
+     */
+    public fun getSpatialCapabilities(): SpatialCapabilities =
+        runtime.spatialCapabilities.toSpatialCapabilities()
+
+    /**
+     * Adds the given [Consumer] as a listener to be invoked when this Session's current
+     * [SpatialCapabilities] change. [Consumer#accept(SpatialCapabilities)] will be invoked on the
+     * main thread.
+     */
+    public fun addSpatialCapabilitiesChangedListener(
+        listener: Consumer<SpatialCapabilities>
+    ): Unit = addSpatialCapabilitiesChangedListener(HandlerExecutor.mainThreadExecutor, listener)
+
+    /**
+     * Adds the given [Consumer] as a listener to be invoked when this Session's current
+     * [SpatialCapabilities] change. [Consumer#accept(SpatialCapabilities)] will be invoked on the
+     * given callbackExecutor, or the main thread if the callbackExecutor is null (default).
+     */
+    public fun addSpatialCapabilitiesChangedListener(
+        callbackExecutor: Executor,
+        listener: Consumer<SpatialCapabilities>,
+    ): Unit {
+        // wrap the client's listener in a callback that receives & converts the runtime
+        // SpatialCapabilities type.
+        val rtListener: Consumer<RtSpatialCapabilities> =
+            Consumer<RtSpatialCapabilities> { rtCaps: RtSpatialCapabilities ->
+                listener.accept(rtCaps.toSpatialCapabilities())
+            }
+        spatialCapabilitiesListeners.compute(
+            listener,
+            { _, _ ->
+                runtime.addSpatialCapabilitiesChangedListener(callbackExecutor, rtListener)
+                rtListener
+            },
+        )
+    }
+
+    /**
+     * Releases the given [Consumer] from receiving updates when the Session's [SpatialCapabilities]
+     * change.
+     */
+    @Suppress("PairedRegistration") // The corresponding remove method does not accept an Executor
+    public fun removeSpatialCapabilitiesChangedListener(
+        listener: Consumer<SpatialCapabilities>
+    ): Unit {
+        spatialCapabilitiesListeners.computeIfPresent(
+            listener,
+            { _, rtListener ->
+                runtime.removeSpatialCapabilitiesChangedListener(rtListener)
+                null
+            },
+        )
+    }
+
+    /**
+     * If the primary Activity for this Session has focus, causes it to be placed in FullSpace Mode.
+     * Otherwise, this call does nothing.
+     */
+    public fun requestFullSpaceMode(): Unit = runtime.requestFullSpaceMode()
+
+    /**
+     * If the primary Activity for this Session has focus, causes it to be placed in HomeSpace Mode.
+     * Otherwise, this call does nothing.
+     */
+    public fun requestHomeSpaceMode(): Unit = runtime.requestHomeSpaceMode()
+
+    /**
+     * Public factory function for a [GltfModel], where the glTF is asynchronously loaded.
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     *
+     * Currently, only URLs and relative paths from the android_assets/ directory are supported.
+     * Currently, only binary glTF (.glb) files are supported.
+     *
+     * @param name The URL or asset-relative path of a binary glTF (.glb) model to be loaded
+     * @return a ListenableFuture<GltfModel>. Listeners will be called on the main thread if
+     *   Runnable::run is supplied.
+     */
+    @MainThread
+    public fun createGltfResourceAsync(name: String): ListenableFuture<GltfModel> {
+        return GltfModel.createAsync(runtime, name)
+    }
+
+    /**
+     * Public factory function for an EXRImage, where the EXR is loaded from a local file.
+     *
+     * @param name The path for an EXR image to be loaded
+     * @return an EXRImage instance.
+     */
+    public fun createExrImageResource(name: String): ExrImage = ExrImage.create(runtime, name)
+
+    /**
+     * Public factory function for a [GltfModelEntity].
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     *
+     * @param model The [GltfModel] this Entity is referencing.
+     * @param pose The initial pose of the entity.
+     * @return a GltfModelEntity instance
+     */
+    // TODO: b/341372472 - Rename createGltfEntity to createGltfModelEntity
+    @JvmOverloads
+    @MainThread
+    public fun createGltfEntity(model: GltfModel, pose: Pose = Pose.Identity): GltfModelEntity =
+        GltfModelEntity.create(runtime, entityManager, model, pose)
+
+    /**
+     * Public factory function for a StereoSurfaceEntity.
+     *
+     * This method must be called from the main thread.
+     * https://developer.android.com/guide/components/processes-and-threads
+     *
+     * @param stereoMode Stereo mode for the surface.
+     * @param dimensions Dimensions for the surface.
+     * @param pose Pose of this entity relative to its parent, default value is Identity.
+     * @return a StereoSurfaceEntity instance
+     */
+    @MainThread
+    @JvmOverloads
+    public fun createStereoSurfaceEntity(
+        @StereoSurfaceEntity.StereoModeValue
+        stereoMode: Int = StereoSurfaceEntity.StereoMode.SIDE_BY_SIDE,
+        dimensions: Dimensions = Dimensions(1.0f, 1.0f, 1.0f),
+        pose: Pose = Pose.Identity,
+    ): StereoSurfaceEntity {
+        return StereoSurfaceEntity.create(runtime, entityManager, stereoMode, dimensions, pose)
+    }
+
+    // TODO(b/352629832): Update surfaceDimensionsPx to be a PixelDimensions
+    /**
+     * Public factory function for a spatialized PanelEntity.
+     *
+     * @param view View to embed in this panel entity.
+     * @param surfaceDimensionsPx Dimensions for the underlying surface for the given view.
+     * @param dimensions Dimensions for the panel in meters.
+     * @param name Name of the panel.
+     * @param pose Pose of this entity relative to its parent, default value is Identity.
+     * @return a PanelEntity instance.
+     */
+    @JvmOverloads
+    public fun createPanelEntity(
+        view: View,
+        surfaceDimensionsPx: Dimensions,
+        dimensions: Dimensions,
+        name: String,
+        pose: Pose = Pose.Identity,
+    ): PanelEntity =
+        PanelEntity.create(
+            runtime,
+            entityManager,
+            view,
+            surfaceDimensionsPx,
+            dimensions,
+            name,
+            activity,
+            pose,
+        )
+
+    /** Helper function to query if given activity can be a host to ActivityPanel. */
+    @Deprecated(
+        message = "Use getSpatialCapabilities.hasCapabilility(SPATIAL_CAPABILITY_EMBED_ACTIVITY)"
+    )
+    public fun canEmbedActivityPanel(@Suppress("UNUSED_PARAMETER") activity: Activity): Boolean =
+        getSpatialCapabilities()
+            .hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY)
+
+    // TODO(b/352629832): Update windowBoundsPx to be a PixelDimensions
+    /**
+     * Public factory function for a spatial ActivityPanelEntity.
+     *
+     * @param windowBoundsPx Bounds for the panel window in pixels.
+     * @param name Name of the panel.
+     * @param pose Pose of this entity relative to its parent, default value is Identity.
+     * @return an ActivityPanelEntity instance.
+     */
+    @JvmOverloads
+    public fun createActivityPanelEntity(
+        windowBoundsPx: Rect,
+        name: String,
+        pose: Pose = Pose.Identity,
+    ): ActivityPanelEntity =
+        ActivityPanelEntity.create(
+            runtime,
+            entityManager,
+            PixelDimensions(windowBoundsPx.width(), windowBoundsPx.height()),
+            name,
+            activity,
+            pose,
+        )
+
+    /**
+     * Public factory function for an AnchorEntity which searches for a location to create an Anchor
+     * among the tracked planes available to the perception system.
+     *
+     * Note that this function will fail if the application has not been granted the
+     * "android.permission.SCENE_UNDERSTANDING" permission. Consider using PermissionHelper to help
+     * request permission from the User.
+     *
+     * @param bounds Bounds for this AnchorEntity.
+     * @param planeType Orientation of plane to which this Anchor should attach.
+     * @param planeSemantic Semantics of the plane to which this Anchor should attach.
+     * @param timeout The amount of time as a [Duration] to search for the a suitable plane to
+     *   attach to. If a plane is not found within the timeout, the returned AnchorEntity state will
+     *   be set to AnchorEntity.State.TIMEDOUT. It may take longer than the timeout period before
+     *   the anchor state is updated. If the timeout duration is zero it will search for the anchor
+     *   indefinitely.
+     */
+    @JvmOverloads
+    public fun createAnchorEntity(
+        bounds: Dimensions,
+        planeType: @PlaneTypeValue Int,
+        planeSemantic: @PlaneSemanticValue Int,
+        timeout: Duration = Duration.ZERO,
+    ): AnchorEntity {
+        return AnchorEntity.create(
+            runtime,
+            entityManager,
+            bounds,
+            planeType,
+            planeSemantic,
+            timeout
+        )
+    }
+
+    /**
+     * Public factory function for an AnchorEntity which uses an Anchor from ARCore for XR.
+     *
+     * @param anchor The PerceptionAnchor to use for this AnchorEntity.
+     */
+    public fun createAnchorEntity(anchor: Anchor): AnchorEntity {
+        return AnchorEntity.create(runtime, entityManager, anchor)
+    }
+
+    /**
+     * Unpersists an anchor. It will clean up the data in the storage that is required to retrieve
+     * the anchor.
+     *
+     * @param uuid UUID of the anchor to unpersist.
+     */
+    public fun unpersistAnchor(uuid: UUID): Boolean {
+        return runtime.unpersistAnchor(uuid)
+    }
+
+    /**
+     * Public factory function for creating a content-less entity. This entity is used as a
+     * connection point for attaching children entities and managing them (i.e. setPose()) as a
+     * group.
+     *
+     * @param name Name of the entity.
+     * @param pose Initial pose of the entity.
+     */
+    @JvmOverloads
+    public fun createEntity(name: String, pose: Pose = Pose.Identity): Entity =
+        ContentlessEntity.create(runtime, entityManager, name, pose)
+
+    /**
+     * Public factory function for a persisted AnchorEntity using UUID. Note that the system keeps a
+     * limited number of scenes and anchors. If the anchor is pruned due to the system limitation,
+     * the creation will fail. It will return null if there is a failure.
+     *
+     * @param uuid The UUID of the persisted anchor to recreate.
+     * @return a persisted AnchorEntity instance.
+     */
+    public fun createPersistedAnchorEntity(uuid: UUID): AnchorEntity {
+        return AnchorEntity.create(runtime, entityManager, uuid)
+    }
+
+    /**
+     * Public factory for creating an [InteractableComponent]. It enables access to raw input
+     * events.
+     *
+     * @param executor Executor for invoking [InputEventListener].
+     * @param inputEventListener [InputEventListener] that accepts [InputEvent]s.
+     * @return [InteractableComponent] instance.
+     */
+    @Suppress("ExecutorRegistration")
+    public fun createInteractableComponent(
+        executor: Executor,
+        inputEventListener: InputEventListener,
+    ): InteractableComponent =
+        InteractableComponent.create(runtime, entityManager, executor, inputEventListener)
+
+    /**
+     * Public factory function for creating a MovableComponent. This component can be attached to a
+     * single instance of any non-Anchor Entity.
+     *
+     * When attached, this Component will enable the user to translate the Entity by pointing and
+     * dragging on it.
+     *
+     * @param systemMovable A [Boolean] which causes the system to automatically apply transform
+     *   updates to the entity in response to user interaction.
+     * @param scaleInZ A [Boolean] which tells the system to update the scale of the Entity as the
+     *   user moves it closer and further away. This is mostly useful for Panel auto-rescaling with
+     *   Distance
+     * @param anchorPlacement A Set containing different [AnchorPlacement] for how to anchor the
+     *   [Entity] movable component. If this is not empty the movement semantics will be slightly
+     *   different from the system as it will add the ability to anchor to nearby planes.
+     * @param shouldDisposeParentAnchor A [Boolean], which if set to true, when an entity is moved
+     *   off of an [AnchorEntity] that was created by the underlying [MovableComponent], and the
+     *   [AnchorEntity] has no other children, the AnchorEntity will be disposed, and the underlying
+     *   Anchor will be detached.
+     * @return [MovableComponent] instance.
+     */
+    @JvmOverloads
+    public fun createMovableComponent(
+        systemMovable: Boolean = true,
+        scaleInZ: Boolean = true,
+        anchorPlacement: Set<AnchorPlacement> = emptySet(),
+        shouldDisposeParentAnchor: Boolean = true,
+    ): MovableComponent =
+        MovableComponent.create(
+            runtime = runtime,
+            entityManager = entityManager,
+            systemMovable = systemMovable,
+            scaleInZ = scaleInZ,
+            anchorPlacement = anchorPlacement,
+            shouldDisposeParentAnchor = shouldDisposeParentAnchor,
+        )
+
+    /**
+     * Public factory function for creating a ResizableComponent. This component can be attached to
+     * a single instance of any non-Anchor Entity.
+     *
+     * When attached, this Component will enable the user to resize the Entity by dragging along the
+     * boundaries of the interaction highlight.
+     *
+     * @param minimumSize A lower bound for the User's resize actions, in meters. This value is used
+     *   to set constraints on how small the user can resize the bounding box of the entity down to.
+     *   The size of the content inside that bounding box is fully controlled by the application.
+     *   The default value for this param is 0 meters.
+     * @param maximumSize An upper bound for the User's resize actions, in meters. This value is
+     *   used to set constraints on how large the user can resize the bounding box of the entity up
+     *   to. The size of the content inside that bounding box is fully controlled by the
+     *   application. The default value for this param is 10 meters.
+     * @return [ResizableComponent] instance.
+     */
+    @JvmOverloads
+    public fun createResizableComponent(
+        minimumSize: Dimensions = ResizableComponent.kMinimumSize,
+        maximumSize: Dimensions = ResizableComponent.kMaximumSize,
+    ): ResizableComponent = ResizableComponent.create(runtime, minimumSize, maximumSize)
+
+    /**
+     * Sets the full space mode flag to the given [android.os.Bundle].
+     *
+     * The [android.os.Bundle] then could be used to launch an [android.app.Activity] with
+     * requesting to enter full space mode through [android.app.Activity.startActivity]. If there's
+     * a bundle used for customizing how the [android.app.Activity] should be started by
+     * [android.app.ActivityOptions.toBundle] or [androidx.core.app.ActivityOptionsCompat.toBundle],
+     * it's suggested to use the bundle to call this method.
+     *
+     * The flag will be ignored when no [android.content.Intent.FLAG_ACTIVITY_NEW_TASK] is set in
+     * the bundle, or it is not started from a focused Activity context.
+     *
+     * This flag is also ignored when the [android.window.PROPERTY_XR_ACTIVITY_START_MODE] property
+     * is set to a value other than [XR_ACTIVITY_START_MODE_UNDEFINED] in the AndroidManifest.xml
+     * file for the activity being launched.
+     *
+     * @param bundle the input bundle to set with the full space mode flag.
+     * @return the input bundle with the full space mode flag set.
+     */
+    public fun setFullSpaceMode(bundle: Bundle): Bundle = runtime.setFullSpaceMode(bundle)
+
+    /**
+     * Sets the inherit full space mode environvment flag to the given [android.os.Bundle].
+     *
+     * The [android.os.Bundle] then could be used to launch an [android.app.Activity] with
+     * requesting to enter full space mode while inherit the existing environment through
+     * [android.app.Activity.startActivity]. If there's a bundle used for customizing how the
+     * [android.app.Activity] should be started by [android.app.ActivityOptions.toBundle] or
+     * [androidx.core.app.ActivityOptionsCompat.toBundle], it's suggested to use the bundle to call
+     * this method.
+     *
+     * When launched, the activity will be in full space mode and also inherits the environment from
+     * the launching activity. If the inherited environment needs to be animated, the launching
+     * activity has to continue updating the environment even after the activity is put into the
+     * stopped state.
+     *
+     * The flag will be ignored when no [android.content.Intent.FLAG_ACTIVITY_NEW_TASK] is set in
+     * the intent, or it is not started from a focused Activity context.
+     *
+     * The flag will also be ignored when there is no environment to inherit or the activity has its
+     * own environment set already.
+     *
+     * This flag is ignored too when the [android.window.PROPERTY_XR_ACTIVITY_START_MODE] property
+     * is set to a value other than [XR_ACTIVITY_START_MODE_UNDEFINED] in the AndroidManifest.xml
+     * file for the activity being launched.
+     *
+     * For security reasons, Z testing for the new activity is disabled, and the activity is always
+     * drawn on top of the inherited environment. Because Z testing is disabled, the activity should
+     * not spatialize itself, and should not curve its panel too much either.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @return the input bundle with the inherit full space mode flag set.
+     */
+    public fun setFullSpaceModeWithEnvironmentInherited(bundle: Bundle): Bundle =
+        runtime.setFullSpaceModeWithEnvironmentInherited(bundle)
+
+    /**
+     * Sets a preferred main panel aspect ratio for home space mode.
+     *
+     * The ratio is only applied to the activity. If the activity launches another activity in the
+     * same task, the ratio is not applied to the new activity. Also, while the activity is in full
+     * space mode, the preference is temporarily removed.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *   height. A value <= 0.0f means there are no preferences.
+     */
+    public fun setPreferredAspectRatio(activity: Activity, preferredRatio: Float): Unit =
+        runtime.setPreferredAspectRatio(activity, preferredRatio)
+
+    /**
+     * Returns all [Entity]s of the given type or its subtypes.
+     *
+     * @param type the type of [Entity] to return.
+     * @return a list of all [Entity]s of the given type.
+     */
+    public fun <T : Entity> getEntitiesOfType(type: Class<out T>): List<T> =
+        entityManager.getEntitiesOfType(type)
+
+    internal fun getEntityForRtEntity(entity: RtEntity): Entity? {
+        return entityManager.getEntityForRtEntity(entity)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SoundFieldAttributes.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SoundFieldAttributes.kt
new file mode 100644
index 0000000..3a631ad
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SoundFieldAttributes.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+
+/** Configures ambisonics sound sources. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SoundFieldAttributes(@SpatializerConstants.AmbisonicsOrder public val order: Int) {
+
+    internal val rtSoundFieldAttributes: JxrPlatformAdapter.SoundFieldAttributes
+
+    init {
+        val rtOrder =
+            when (order) {
+                SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER ->
+                    JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER
+                SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER ->
+                    JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER
+                SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER ->
+                    JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER
+                else -> {
+                    Log.e(TAG, "Unknown ambisonics order.")
+                    order
+                }
+            }
+
+        rtSoundFieldAttributes = JxrPlatformAdapter.SoundFieldAttributes(rtOrder)
+    }
+
+    private companion object {
+        const val TAG = "SoundFieldAttributes"
+    }
+}
+
+internal fun JxrPlatformAdapter.SoundFieldAttributes.toSoundFieldAttributes():
+    SoundFieldAttributes {
+    return SoundFieldAttributes(this.ambisonicsOrder.ambisonicsOrderToJxr())
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialAudioTrack.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialAudioTrack.kt
new file mode 100644
index 0000000..6ce3da5
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialAudioTrack.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.media.AudioTrack
+import androidx.annotation.RestrictTo
+
+@Suppress("ClassShouldBeObject")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialAudioTrack {
+
+    public companion object {
+        /**
+         * Gets the [SourceType] of the provided [AudioTrack]. This value is implicitly set
+         * depending one which type of attributes was used to configure the builder. Will return
+         * [SpatializerExtensions.NOT_SPATIALIZED] for tracks that didn't use spatial audio
+         * attributes.
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param track The [AudioTrack] from which to get the [SpatializerConstants.SourceType].
+         * @return The [SpatializerExtensions.SourceType] of the provided track.
+         */
+        @JvmStatic
+        @SpatializerConstants.SourceType
+        public fun getSpatialSourceType(session: Session, track: AudioTrack): Int {
+            return session.runtime.audioTrackExtensionsWrapper
+                .getSpatialSourceType(track)
+                .sourceTypeToJxr()
+        }
+
+        /**
+         * Gets the [PointSourceAttributes] of the provided [AudioTrack].
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param track The [AudioTrack] from which to get the [PointSourceAttributes].
+         * @return The [PointSourceAttributes] of the provided track, null if not set.
+         */
+        @JvmStatic
+        public fun getPointSourceAttributes(
+            session: Session,
+            track: AudioTrack,
+        ): PointSourceAttributes? {
+            val rtAttributes =
+                session.runtime.audioTrackExtensionsWrapper.getPointSourceAttributes(track)
+            return rtAttributes?.toPointSourceAttributes(session)
+        }
+
+        /**
+         * Gets the [SoundFieldAttributes] of the provided [AudioTrack].
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param track The [AudioTrack] from which to get the [SoundFieldAttributes].
+         * @return The [SoundFieldAttributes] of the provided track, null if not set.
+         */
+        @JvmStatic
+        public fun getSoundFieldAttributes(
+            session: Session,
+            track: AudioTrack
+        ): SoundFieldAttributes? {
+            val rtAttributes =
+                session.runtime.audioTrackExtensionsWrapper.getSoundFieldAttributes(track)
+            return rtAttributes?.toSoundFieldAttributes()
+        }
+    }
+}
+
+/** Provides spatial audio extensions on the platform [AudioTrack.Builder] class. */
+@Suppress("ClassShouldBeObject", "MissingBuildMethod", "TopLevelBuilder")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialAudioTrackBuilder private constructor() {
+
+    public companion object {
+        /**
+         * Sets the [PointSourceAttributes] on the provided [AudioTrack.Builder].
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param builder The Builder on which to set the attributes.
+         * @param attributes The source attributes to be set.
+         * @return The same [AudioTrack.Builder] instance provided.
+         */
+        @Suppress("SetterReturnsThis")
+        @JvmStatic
+        public fun setPointSourceAttributes(
+            session: Session,
+            builder: AudioTrack.Builder,
+            attributes: PointSourceAttributes,
+        ): AudioTrack.Builder {
+
+            return session.runtime.audioTrackExtensionsWrapper.setPointSourceAttributes(
+                builder,
+                attributes.rtPointSourceAttributes,
+            )
+        }
+
+        /**
+         * Sets the [SoundFieldAttributes] on the provided [AudioTrack.Builder].
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param builder The Builder on which to set the attributes.
+         * @param attributes The source attributes to be set.
+         * @return The same [AudioTrack.Builder] instance provided.
+         */
+        @Suppress("SetterReturnsThis")
+        @JvmStatic
+        public fun setSoundFieldAttributes(
+            session: Session,
+            builder: AudioTrack.Builder,
+            attributes: SoundFieldAttributes,
+        ): AudioTrack.Builder {
+            return session.runtime.audioTrackExtensionsWrapper.setSoundFieldAttributes(
+                builder,
+                attributes.rtSoundFieldAttributes,
+            )
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialCapabilities.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialCapabilities.kt
new file mode 100644
index 0000000..642e41f
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialCapabilities.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+
+/** Representation of the spatial capabilities of the current session. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialCapabilities(@SpatialCapability private val capabilities: Int) {
+
+    public companion object {
+        /** The activity can spatialize itself by e.g. adding a spatial panel. */
+        public const val SPATIAL_CAPABILITY_UI: Int = 1 shl 0
+
+        /** The activity can create 3D contents. */
+        public const val SPATIAL_CAPABILITY_3D_CONTENT: Int = 1 shl 1
+
+        /** The activity can enable or disable passthrough. */
+        public const val SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL: Int = 1 shl 2
+
+        /** The activity can set its own environment. */
+        public const val SPATIAL_CAPABILITY_APP_ENVIRONMENT: Int = 1 shl 3
+
+        /** The activity can use spatial audio. */
+        public const val SPATIAL_CAPABILITY_SPATIAL_AUDIO: Int = 1 shl 4
+
+        /** The activity can spatially embed another activity. */
+        public const val SPATIAL_CAPABILITY_EMBED_ACTIVITY: Int = 1 shl 5
+    }
+
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        flag = true,
+        value =
+            [
+                SPATIAL_CAPABILITY_UI,
+                SPATIAL_CAPABILITY_3D_CONTENT,
+                SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL,
+                SPATIAL_CAPABILITY_APP_ENVIRONMENT,
+                SPATIAL_CAPABILITY_SPATIAL_AUDIO,
+                SPATIAL_CAPABILITY_EMBED_ACTIVITY,
+            ],
+    )
+    internal annotation class SpatialCapability
+
+    public fun hasCapability(@SpatialCapability capability: Int): Boolean =
+        (capabilities and capability) != 0
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SpatialCapabilities) return false
+
+        if (capabilities != other.capabilities) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        return capabilities
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialEnvironment.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialEnvironment.kt
new file mode 100644
index 0000000..b6fd2d7
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialEnvironment.kt
@@ -0,0 +1,454 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("JVM_FIELD", "Deprecation")
+
+package androidx.xr.scenecore
+
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult as RtSetPassthroughOpacityPreferenceResult
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult as RtSetSpatialEnvironmentPreferenceResult
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference as RtSpatialEnvironmentPreference
+import com.google.errorprone.annotations.CanIgnoreReturnValue
+import java.util.concurrent.atomic.AtomicReference
+import java.util.function.Consumer
+
+// TODO: Support a nullable Runtime in the ctor. This should allow the Runtime to "show up" later.
+
+/**
+ * The SpatialEnvironment is used to manage the XR background and passthrough. There is a single
+ * instance of this class managed by each SceneCore Session (which is bound to an [Activity].)
+ *
+ * The SpatialEnvironment is a composite of a stand-alone skybox, and glTF-specified geometry. A
+ * single skybox and a single glTF can be set at the same time. Applications are encouraged to
+ * supply glTFs for ground and horizon visibility.
+ *
+ * The XR background can be set to display one of three configurations:
+ * 1) A combination of a skybox and glTF geometry.
+ * 2) A Passthrough surface, where the XR background is a live feed from the device's outward facing
+ *    cameras. At full opacity, this surface completely occludes the skybox and geometry.
+ * 3) A mixed configuration where the passthrough surface is not at full opacity nor is it at zero
+ *    opacity. The passthrough surface becomes semi-transparent and alpha blends with the skybox and
+ *    geometry behind it.
+ *
+ * Note that methods in this class do not necessarily take effect immediately. Rather, they set a
+ * preference that will be applied when the device enters a state where the XR background can be
+ * changed.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialEnvironment(runtime: JxrPlatformAdapter) {
+
+    private val TAG = "SpatialEnvironment"
+
+    private val rtEnvironment: JxrPlatformAdapter.SpatialEnvironment = runtime.spatialEnvironment
+
+    // These two fields are only used by the deprecated setSkybox() and setGeometry() methods.
+    // TODO: b/370015943 - Remove after clients migrate to the SpatialEnvironmentPreference APIs.
+    private val deprecatedSkybox = AtomicReference<ExrImage?>()
+    private val deprecatedGeometry = AtomicReference<GltfModel?>()
+
+    // TODO: b/370484799 - Remove this once all clients move away from it.
+    /** Describes if/how the User can view their real-world physical environment. */
+    @Deprecated(message = "Use isSpatialEnvironmentPreferenceActive() instead.")
+    public class PassthroughMode internal constructor(public val value: Int) {
+        public companion object {
+            /** The state at startup. The application cannot set this state. No longer used. */
+            @JvmField public val Uninitialized: PassthroughMode = PassthroughMode(0)
+            /**
+             * The user's passthrough is not composed into their view. Environment skyboxes and
+             * geometry are only visible in this state.
+             */
+            @JvmField public val Disabled: PassthroughMode = PassthroughMode(1)
+            /** The user's passthrough is visible at full or partial opacity. */
+            @JvmField public val Enabled: PassthroughMode = PassthroughMode(2)
+        }
+    }
+
+    /**
+     * Represents the preferred spatial environment for the application.
+     *
+     * @param skybox The preferred skybox for the environment based on a pre-loaded EXR Image. If
+     *   null, it will be all black.
+     * @param geometry The preferred geometry for the environment based on a pre-loaded [GltfModel].
+     *   If null, there will be no geometry.
+     */
+    public class SpatialEnvironmentPreference(
+        public val skybox: ExrImage?,
+        public val geometry: GltfModel?,
+    ) {
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (javaClass != other?.javaClass) return false
+
+            other as SpatialEnvironmentPreference
+
+            if (skybox != other.skybox) return false
+            if (geometry != other.geometry) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = skybox?.hashCode() ?: 0
+            result = 31 * result + (geometry?.hashCode() ?: 0)
+            return result
+        }
+    }
+
+    // TODO: b/370484799 - Remove this once all clients migrate to the opacity Preference APIs.
+    /**
+     * Sets the preference for passthrough.
+     *
+     * Calling with DISABLED is equivalent to calling setPassthroughOpacityPreference(0.0f) and
+     * calling with ENABLED is equivalent to calling setPassthroughOpacityPreference(1.0f). Calling
+     * with UNINITIALIZED is ignored. See [setPassthroughOpacityPreference] for more details.
+     */
+    @Deprecated(message = "Use setPassthroughOpacityPreference instead.")
+    public fun setPassthrough(passthroughMode: PassthroughMode) {
+        when (passthroughMode) {
+            PassthroughMode.Uninitialized -> return // Do nothing. This isn't allowed.
+            PassthroughMode.Disabled -> setPassthroughOpacityPreference(0.0f)
+            PassthroughMode.Enabled -> setPassthroughOpacityPreference(1.0f)
+        }
+    }
+
+    // TODO: b/370484799 - Remove this once all clients migrate to the opacity Preference APIs.
+    /**
+     * Sets the preference for passthrough. This is equivalent to calling
+     * [setPassthroughOpacityPreference] with the given opacity value.
+     */
+    @Deprecated(message = "Use setPassthroughOpacityPreference instead.")
+    public fun setPassthroughOpacity(passthroughOpacity: Float) {
+        setPassthroughOpacityPreference(passthroughOpacity)
+    }
+
+    // TODO: b/370484799 - Remove this once all clients migrate to the opacity Preference APIs.
+    /** Gets the current preference for passthrough mode. */
+    @Deprecated(message = "Use getCurrentPassthroughOpacity instead.")
+    public fun getPassthroughMode(): PassthroughMode {
+        if (getCurrentPassthroughOpacity() > 0.0f) {
+            return PassthroughMode.Enabled
+        } else {
+            return PassthroughMode.Disabled
+        }
+    }
+
+    // TODO: b/370484799 - Remove this once all clients migrate to the opacity Preference APIs.
+    /**
+     * Gets the current passthrough opacity. This may be different than the passthrough opacity
+     * preference.
+     */
+    @Deprecated(message = "Use getCurrentPassthroughOpacity instead.")
+    public fun getPassthroughOpacity(): Float {
+        return getCurrentPassthroughOpacity()
+    }
+
+    /**
+     * Sets the application's preferred passthrough opacity between 0.0f and 1.0f. Upon
+     * construction, the default value is null, which means "no application preference".
+     *
+     * Setting the application preference through this method does not guarantee that the value will
+     * be immediately applied and visible to the user. The actual passthrough opacity value is
+     * controlled by the system in response to a combination of the application's preference and
+     * user actions outside the application. Generally, the application's preference will be shown
+     * to the user when the application has the
+     * [SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL] capability. The current value
+     * visible to the user can be observed by calling [getCurrentPassthroughOpacity] or by
+     * registering a listener with [addOnPassthroughOpacityChangedListener].
+     *
+     * @param passthroughOpacityPreference The application's passthrough opacity preference between
+     *   0.0f (disabled with no passthrough) and 1.0f (fully enabled passthrough hides the spatial
+     *   environment). Values within 0.01f of 0.0 or 1.0 will be snapped to those values. Other
+     *   values result in semi-transparent passthrough alpha blended with the spatial environment.
+     *   Values outside [0.0f, 1.0f] are clamped. If null, the system will manage the passthrough
+     *   opacity.
+     * @return The result of the call to set the passthrough opacity preference. If the preference
+     *   was successfully set and applied, the result will be
+     *   [SetPassthroughOpacityPreferenceChangeApplied]. If the preference was set, but it cannot be
+     *   currently applied, the result will be [SetPassthroughOpacityPreferenceChangePending].
+     */
+    @CanIgnoreReturnValue
+    public fun setPassthroughOpacityPreference(
+        @SuppressWarnings("AutoBoxing") passthroughOpacityPreference: Float?
+    ): SetPassthroughOpacityPreferenceResult {
+        return rtEnvironment
+            .setPassthroughOpacityPreference(passthroughOpacityPreference)
+            .toSetPassthroughOpacityPreferenceResult()
+    }
+
+    /**
+     * Gets the current passthrough opacity value visible to the user.
+     *
+     * Unlike the application's opacity preference returned by [getPassthroughOpacityPreference],
+     * this value can be overwritten by the system, and is not directly under the application's
+     * control.
+     *
+     * @return The current passthrough opacity value between 0.0f and 1.0f. A value of 0.0f means no
+     *   passthrough is shown, and a value of 1.0f means the passthrough completely obscures the
+     *   spatial environment geometry and skybox.
+     */
+    public fun getCurrentPassthroughOpacity(): Float {
+        return rtEnvironment.currentPassthroughOpacity
+    }
+
+    /**
+     * Gets the current passthrough opacity preference set through
+     * [setPassthroughOpacityPreference]. Defaults to null if [setPassthroughOpacityPreference] has
+     * not been called.
+     *
+     * This value only reflects the application's preference and does not necessarily reflect what
+     * the system is currently showing the user. See [getCurrentPassthroughOpacity] to get the
+     * actual visible opacity value.
+     *
+     * @return The last passthrough opacity value between 0.0f and 1.0f requested through
+     *   [setPassthroughOpacityPreference]. If null, no application preference is set and the
+     *   passthrough opacity will be fully managed through the system.
+     */
+    @SuppressWarnings("AutoBoxing")
+    public fun getPassthroughOpacityPreference(): Float? {
+        return rtEnvironment.passthroughOpacityPreference
+    }
+
+    /**
+     * Notifies an application when the user visible passthrough state changes, such as when the
+     * application enters or exits passthrough or when the passthrough opacity changes.
+     *
+     * This [listener] will be called on the Application's main thread.
+     *
+     * @param listener The [Consumer<Float>] to be added to listen for passthrough opacity changes.
+     */
+    public fun addOnPassthroughOpacityChangedListener(listener: Consumer<Float>) {
+        rtEnvironment.addOnPassthroughOpacityChangedListener(listener)
+    }
+
+    /**
+     * Remove a listener previously added by [addOnPassthroughOpacityChangedListener].
+     *
+     * @param listener The previously-added [Consumer<Float>] listener to be removed.
+     */
+    public fun removeOnPassthroughOpacityChangedListener(listener: Consumer<Float>) {
+        rtEnvironment.removeOnPassthroughOpacityChangedListener(listener)
+    }
+
+    // TODO: b/370015943 - Remove this once all clients migrate to the SpatialEnvironment APIs.
+    /**
+     * Sets the preferred environmental skybox based on a pre-loaded EXR Image.
+     *
+     * Note that this method does not necessarily cause an immediate change, it only sets a
+     * preference. Once the device enters a state where the XR background can be changed, the
+     * preference will be applied.
+     *
+     * Setting the skybox to null will disable the skybox.
+     */
+    @Deprecated(message = "Use setSpatialEnvironmentPreference() instead.")
+    public fun setSkybox(exrImage: ExrImage?) {
+        Log.w(TAG, "setGeometry() is deprecated. Use setSpatialEnvironmentPreference() instead.")
+        deprecatedSkybox.updateAndGet {
+            setSpatialEnvironmentPreference(
+                SpatialEnvironmentPreference(exrImage, deprecatedGeometry.get())
+            )
+            exrImage
+        }
+    }
+
+    // TODO: b/370015943 - Remove this once all clients migrate to the SpatialEnvironment APIs.
+    /**
+     * Sets the preferred environmental geometry based on a pre-loaded [GltfModel].
+     *
+     * Note that this method does not necessarily cause an immediate change, it only sets a
+     * preference. Once the device enters a state where the XR background can be changed, the
+     * preference will be applied.
+     *
+     * Setting the geometry to null will disable the geometry.
+     */
+    @Deprecated(message = "Use setSpatialEnvironmentPreference() instead.")
+    public fun setGeometry(gltfModel: GltfModel?) {
+        Log.w(TAG, "setGeometry() is deprecated. Use setSpatialEnvironmentPreference() instead.")
+        deprecatedGeometry.updateAndGet {
+            setSpatialEnvironmentPreference(
+                SpatialEnvironmentPreference(deprecatedSkybox.get(), gltfModel)
+            )
+            gltfModel
+        }
+    }
+
+    /**
+     * Returns true if the environment set by [setSpatialEnvironmentPreference] is active.
+     *
+     * Spatial environment preference set through [setSpatialEnvironmentPreference] are shown when
+     * this is true, but passthrough or other objects in the scene could partially or totally
+     * occlude them. When this is false, the default system environment will be active instead.
+     *
+     * @return True if the environment set by [setSpatialEnvironmentPreference] is active.
+     */
+    public fun isSpatialEnvironmentPreferenceActive(): Boolean {
+        return rtEnvironment.isSpatialEnvironmentPreferenceActive
+    }
+
+    /**
+     * Gets the preferred spatial environment for the application.
+     *
+     * The returned value is always what was most recently supplied to
+     * [setSpatialEnvironmentPreference], or null if no preference has been set.
+     *
+     * See [isSpatialEnvironmentPreferenceActive] or the [addOnSpatialEnvironmentChangedListener]
+     * listeners to know when this preference becomes active.
+     *
+     * @return The most recent spatial environment preference supplied to
+     *   [setSpatialEnvironmentPreference]. If null, the default system environment will be
+     *   displayed instead.
+     */
+    public fun getSpatialEnvironmentPreference(): SpatialEnvironmentPreference? {
+        return rtEnvironment.spatialEnvironmentPreference?.toSpatialEnvironmentPreference()
+    }
+
+    /**
+     * Sets the preferred spatial environment for the application.
+     *
+     * Note that this method only sets a preference and does not cause an immediate change unless
+     * [isSpatialEnvironmentPreferenceActive] is already true. Once the device enters a state where
+     * the XR background can be changed and the
+     * [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS] capability is available, the
+     * preferred spatial environment for the application will be automatically displayed.
+     *
+     * Setting the preference to null will disable the preferred spatial environment for the
+     * application, meaning the default system environment will be displayed instead.
+     *
+     * If the given [SpatialEnvironmentPreference] is not null, but all of its properties are null,
+     * then the spatial environment will consist of a black skybox and no geometry
+     * [isSpatialEnvironmentPreferenceActive] is true.
+     *
+     * Changes to the Environment state will be notified via listeners added with
+     * [addOnSpatialEnvironmentChangedListener].
+     *
+     * @param environmentPreference The preferred spatial environment for the application. If null,
+     *   then there is no preference, and the default system environment will be displayed instead.
+     * @return The result of the call to set the spatial environment preference. If the preference
+     *   was successfully set and applied, the result will be
+     *   [SetSpatialEnvironmentPreferenceChangeApplied]. If the preference was set, but it cannot be
+     *   currently applied, the result will be [SetSpatialEnvironmentPreferenceChangePending].
+     */
+    @CanIgnoreReturnValue
+    public fun setSpatialEnvironmentPreference(
+        environmentPreference: SpatialEnvironmentPreference?
+    ): SetSpatialEnvironmentPreferenceResult {
+        return rtEnvironment
+            .setSpatialEnvironmentPreference(
+                environmentPreference?.toRtSpatialEnvironmentPreference()
+            )
+            .toSetSpatialEnvironmentPreferenceResult()
+    }
+
+    // TODO: b/370957362 - Add overloads for the add...Listener methods to take in an executor
+    /**
+     * Notifies an application whether or not the preferred spatial environment for the application
+     * is active.
+     *
+     * The environment will try to transition to the application environment when a non-null
+     * preference is set through [setSpatialEnvironmentPreference] and the application has the
+     * [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS] capability. The environment
+     * preferences will otherwise not be active.
+     *
+     * The listener consumes a boolean value that is true if the environment preference is active
+     * when the listener is notified.
+     *
+     * This listener will be invoked on the Application's main thread.
+     *
+     * @param listener The [Consumer<Boolean>] to be added to listen for spatial environment
+     *   changes.
+     */
+    public fun addOnSpatialEnvironmentChangedListener(listener: Consumer<Boolean>) {
+        rtEnvironment.addOnSpatialEnvironmentChangedListener(listener)
+    }
+
+    /**
+     * Remove a listener previously added by [addOnSpatialEnvironmentChangedListener].
+     *
+     * @param listener The previously-added [Consumer<Boolean>] listener to be removed.
+     */
+    public fun removeOnSpatialEnvironmentChangedListener(listener: Consumer<Boolean>) {
+        rtEnvironment.removeOnSpatialEnvironmentChangedListener(listener)
+    }
+
+    /** Result values for calls to [setPassthroughOpacityPreference] */
+    public sealed class SetPassthroughOpacityPreferenceResult()
+
+    /** The call to [setPassthroughOpacityPreference] succeeded and should now be visible. */
+    public class SetPassthroughOpacityPreferenceChangeApplied :
+        SetPassthroughOpacityPreferenceResult()
+
+    /**
+     * The call to [setPassthroughOpacityPreference] successfully applied the preference, but it is
+     * not immediately visible due to requesting a state change while the activity does not have the
+     * [SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL] capability to control the app
+     * passthrough state. The preference was still set and will be applied when the capability is
+     * gained.
+     */
+    public class SetPassthroughOpacityPreferenceChangePending :
+        SetPassthroughOpacityPreferenceResult()
+
+    /** Result values for calls to SpatialEnvironment.setSpatialEnvironmentPreference */
+    public sealed class SetSpatialEnvironmentPreferenceResult()
+
+    /** The call to [setSpatialEnvironmentPreference] succeeded and should now be visible. */
+    public class SetSpatialEnvironmentPreferenceChangeApplied :
+        SetSpatialEnvironmentPreferenceResult()
+
+    /**
+     * The call to [setSpatialEnvironmentPreference] successfully applied the preference, but it is
+     * not immediately visible due to requesting a state change while the activity does not have the
+     * [SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENTS] capability to control the app
+     * environment state. The preference was still set and will be applied when the capability is
+     * gained.
+     */
+    public class SetSpatialEnvironmentPreferenceChangePending :
+        SetSpatialEnvironmentPreferenceResult()
+}
+
+internal fun SpatialEnvironment.SpatialEnvironmentPreference.toRtSpatialEnvironmentPreference():
+    RtSpatialEnvironmentPreference {
+    return RtSpatialEnvironmentPreference(skybox?.image, geometry?.model)
+}
+
+internal fun RtSpatialEnvironmentPreference.toSpatialEnvironmentPreference():
+    SpatialEnvironment.SpatialEnvironmentPreference {
+    return SpatialEnvironment.SpatialEnvironmentPreference(
+        skybox?.let { ExrImage(it) },
+        geometry?.let { GltfModel(it) },
+    )
+}
+
+internal fun RtSetSpatialEnvironmentPreferenceResult.toSetSpatialEnvironmentPreferenceResult():
+    SpatialEnvironment.SetSpatialEnvironmentPreferenceResult {
+    return when (this) {
+        RtSetSpatialEnvironmentPreferenceResult.CHANGE_APPLIED ->
+            SpatialEnvironment.SetSpatialEnvironmentPreferenceChangeApplied()
+        RtSetSpatialEnvironmentPreferenceResult.CHANGE_PENDING ->
+            SpatialEnvironment.SetSpatialEnvironmentPreferenceChangePending()
+    }
+}
+
+internal fun RtSetPassthroughOpacityPreferenceResult.toSetPassthroughOpacityPreferenceResult():
+    SpatialEnvironment.SetPassthroughOpacityPreferenceResult {
+    return when (this) {
+        RtSetPassthroughOpacityPreferenceResult.CHANGE_APPLIED ->
+            SpatialEnvironment.SetPassthroughOpacityPreferenceChangeApplied()
+        RtSetPassthroughOpacityPreferenceResult.CHANGE_PENDING ->
+            SpatialEnvironment.SetPassthroughOpacityPreferenceChangePending()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialMediaPlayer.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialMediaPlayer.kt
new file mode 100644
index 0000000..66d3432
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialMediaPlayer.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.media.MediaPlayer
+import androidx.annotation.RestrictTo
+
+@Suppress("ClassShouldBeObject")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialMediaPlayer {
+
+    public companion object {
+        /**
+         * Sets a [PointSourceAttributes] on a [MediaPlayer] instance.
+         *
+         * Must be called before prepare(), not compatible with instances created by
+         * MediaPlayer#create(). Only the attributes from the most recent call to
+         * setPointSourceAttributes or [setSoundFieldAttributes] will apply.
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param mediaPlayer The [MediaPlayer] instance on which to set the attributes
+         * @param attributes The source attributes to be set.
+         */
+        @JvmStatic
+        public fun setPointSourceAttributes(
+            session: Session,
+            mediaPlayer: MediaPlayer,
+            attributes: PointSourceAttributes,
+        ) {
+            session.runtime.mediaPlayerExtensionsWrapper.setPointSourceAttributes(
+                mediaPlayer,
+                attributes.rtPointSourceAttributes,
+            )
+        }
+
+        /**
+         * Sets a [SoundFieldAttributes] on a [MediaPlayer] instance.
+         *
+         * Must be called before prepare(), not compatible with instances created by
+         * MediaPlayer#create(). Only the attributes from the most recent call to
+         * setSoundFieldAttributes or [setPointSourceAttributes] will apply.
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param mediaPlayer The [MediaPlayer] instance on which to set the attributes
+         * @param attributes The source attributes to be set.
+         */
+        @JvmStatic
+        public fun setSoundFieldAttributes(
+            session: Session,
+            mediaPlayer: MediaPlayer,
+            attributes: SoundFieldAttributes,
+        ) {
+            session.runtime.mediaPlayerExtensionsWrapper.setSoundFieldAttributes(
+                mediaPlayer,
+                attributes.rtSoundFieldAttributes,
+            )
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialSoundPool.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialSoundPool.kt
new file mode 100644
index 0000000..f0d4de4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialSoundPool.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("UNUSED_PARAMETER")
+
+package androidx.xr.scenecore
+
+import android.media.SoundPool
+import androidx.annotation.RestrictTo
+
+/** Provides spatial audio extensions on the framework [SoundPool] class. */
+@Suppress("ClassShouldBeObject")
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialSoundPool private constructor() {
+
+    public companion object {
+        /**
+         * Plays a spatialized sound effect emitted relative [Node] in the [PointSourceAttributes].
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param soundPool The [SoundPool] to use to the play the sound.
+         * @param soundID a soundId returned by the load() function.
+         * @param attributes attributes to specify sound source. [PointSourceAttributes]
+         * @param volume value (range = 0.0 to 1.0)
+         * @param priority stream priority (0 = lowest priority)
+         * @param loop loop mode (0 = no loop, -1 = loop forever, N = loop N times)
+         * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+         * @return non-zero streamID if successful, zero if failed
+         */
+        @JvmStatic
+        public fun play(
+            session: Session,
+            soundPool: SoundPool,
+            soundID: Int,
+            attributes: PointSourceAttributes,
+            volume: Float,
+            priority: Int,
+            loop: Int,
+            rate: Float,
+        ): Int {
+
+            return session.runtime.soundPoolExtensionsWrapper.play(
+                soundPool,
+                soundID,
+                attributes.rtPointSourceAttributes,
+                volume,
+                priority,
+                loop,
+                rate,
+            )
+        }
+
+        /**
+         * Plays a spatialized sound effect as a sound field.
+         *
+         * @param session The current SceneCore [Session] instance.
+         * @param soundPool The [SoundPool] to use to the play the sound.
+         * @param soundID a soundId returned by the load() function.
+         * @param attributes attributes to specify sound source. [SoundFieldAttributes]
+         * @param volume value (range = 0.0 to 1.0)
+         * @param priority stream priority (0 = lowest priority)
+         * @param loop loop mode (0 = no loop, -1 = loop forever, N = loop N times)
+         * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+         * @return non-zero streamID if successful, zero if failed
+         */
+        @JvmStatic
+        public fun play(
+            session: Session,
+            soundPool: SoundPool,
+            soundID: Int,
+            attributes: SoundFieldAttributes,
+            volume: Float,
+            priority: Int,
+            loop: Int,
+            rate: Float,
+        ): Int {
+
+            return session.runtime.soundPoolExtensionsWrapper.play(
+                soundPool,
+                soundID,
+                attributes.rtSoundFieldAttributes,
+                volume,
+                priority,
+                loop,
+                rate,
+            )
+        }
+
+        /**
+         * @param session The current SceneCore [Session] instance.
+         * @param soundPool The [SoundPool] to use to get its SourceType.
+         * @param streamId a streamID returned by the play(), playAsPointSource(), or
+         *   playAsSoundField().
+         * @return The [SpatializerConstants.SourceType] for the given streamID.
+         */
+        @JvmStatic
+        @SpatializerConstants.SourceType
+        public fun getSpatialSourceType(
+            session: Session,
+            soundPool: SoundPool,
+            streamId: Int
+        ): Int {
+            return session.runtime.soundPoolExtensionsWrapper
+                .getSpatialSourceType(soundPool, streamId)
+                .sourceTypeToJxr()
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialUser.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialUser.kt
new file mode 100644
index 0000000..414cae6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatialUser.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.RestrictTo
+
+/**
+ * The User object is used to retrieve information about the user. This includes the Head and The
+ * CameraViews.
+ *
+ * @param runtime The JxrPlatformAdapter for the Session.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialUser(private val runtime: JxrPlatformAdapter) {
+    private var cachedLeftCamera: CameraView? = null
+    private var cachedRightCamera: CameraView? = null
+    private var cachedHead: Head? = null
+
+    private var leftCamera: CameraView? = null
+        get() {
+            if (cachedLeftCamera == null) {
+                cachedLeftCamera = CameraView.createLeft(runtime)
+            }
+            return cachedLeftCamera
+        }
+
+    private var rightCamera: CameraView? = null
+        get() {
+            if (cachedRightCamera == null) {
+                cachedRightCamera = CameraView.createRight(runtime)
+            }
+            return cachedRightCamera
+        }
+
+    internal companion object {
+        /** Factory function for creating [SpatialUser] instance. */
+        internal fun create(runtime: JxrPlatformAdapter): SpatialUser {
+            return SpatialUser(runtime)
+        }
+    }
+
+    /** Returns a Head for the SpatialUser or null if it is not yet available. */
+    public var head: Head? = null
+        get() {
+            if (cachedHead == null) {
+                cachedHead = Head.create(runtime)
+            }
+            return cachedHead
+        }
+
+    /**
+     * Returns a list of CameraViews that the user is using. The length of the list is dependent on
+     * the device type. The list will be empty if the cameras are not yet available.
+     */
+    public fun getCameraViews(): List<CameraView> {
+        return listOfNotNull<CameraView>(leftCamera, rightCamera)
+    }
+
+    /** Returns a CameraView for the specified CameraType or null if it is not available. */
+    public fun getCameraView(cameraType: CameraView.CameraType): CameraView? {
+        if (cameraType == CameraView.CameraType.LEFT_EYE) {
+            return leftCamera
+        } else if (cameraType == CameraView.CameraType.RIGHT_EYE) {
+            return rightCamera
+        }
+        return null
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatializerConstants.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatializerConstants.kt
new file mode 100644
index 0000000..35cda9c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/SpatializerConstants.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+
+/** Constants for spatialized audio. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface SpatializerConstants {
+
+    public companion object {
+        /** Specifies spatial rendering using First Order Ambisonics */
+        public const val AMBISONICS_ORDER_FIRST_ORDER: Int = 0
+        /** Specifies spatial rendering using Second Order Ambisonics */
+        public const val AMBISONICS_ORDER_SECOND_ORDER: Int = 1
+        /** Specifies spatial rendering using Third Order Ambisonics */
+        public const val AMBISONICS_ORDER_THIRD_ORDER: Int = 2
+
+        /** The sound source has not been spatialized with the Spatial Audio SDK. */
+        public const val SOURCE_TYPE_BYPASS: Int = 0
+        /** The sound source has been spatialized as a 3D point source. */
+        public const val SOURCE_TYPE_POINT_SOURCE: Int = 1
+        /** The sound source is an ambisonics sound field. */
+        public const val SOURCE_TYPE_SOUND_FIELD: Int = 2
+    }
+
+    /** Used to set the Ambisonics order of a [SoundFieldAttributes] */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        value =
+            [
+                AMBISONICS_ORDER_FIRST_ORDER,
+                AMBISONICS_ORDER_SECOND_ORDER,
+                AMBISONICS_ORDER_THIRD_ORDER
+            ]
+    )
+    public annotation class AmbisonicsOrder
+
+    /** Represents the type of spatialization for an audio source. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(value = [SOURCE_TYPE_BYPASS, SOURCE_TYPE_POINT_SOURCE, SOURCE_TYPE_SOUND_FIELD])
+    public annotation class SourceType
+}
+
+/** Converts the [JxrPlatformAdapter] SourceType IntDef to the SceneCore API. */
[email protected]
+internal fun @receiver:JxrPlatformAdapter.SpatializerConstants.SourceType Int.sourceTypeToJxr():
+    Int {
+    return when (this) {
+        JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_BYPASS ->
+            SpatializerConstants.SOURCE_TYPE_BYPASS
+        JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_POINT_SOURCE ->
+            SpatializerConstants.SOURCE_TYPE_POINT_SOURCE
+        JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_SOUND_FIELD ->
+            SpatializerConstants.SOURCE_TYPE_SOUND_FIELD
+        else -> {
+            // Unknown source type, returning bypass.
+            SpatializerConstants.SOURCE_TYPE_BYPASS
+        }
+    }
+}
+
+/** Converts the [JxrPlatformAdapter] SourceType IntDef to the SceneCore API. */
[email protected]
+internal fun @receiver:JxrPlatformAdapter.SpatializerConstants.AmbisonicsOrder
+Int.ambisonicsOrderToJxr(): Int {
+    return when (this) {
+        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER ->
+            SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER
+        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER ->
+            SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER
+        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER ->
+            SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER
+        else -> {
+            // Unknown order, returning first order
+            SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Types.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Types.kt
new file mode 100644
index 0000000..78fa4c6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Types.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+
+/**
+ * Dimensions of a 3D object.
+ *
+ * @param width Width.
+ * @param height Height.
+ * @param depth Depth.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class Dimensions(
+    public val width: Float = 0f,
+    public val height: Float = 0f,
+    public val depth: Float = 0f,
+) {
+    override fun toString(): String {
+        return super.toString() + ": w $width x h $height x d $depth"
+    }
+}
+
+/**
+ * Dimensions of a 2D surface in Pixels.
+ *
+ * @param width Integer Width.
+ * @param height Integer Height.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class PixelDimensions(public val width: Int = 0, public val height: Int = 0) {
+    override fun toString(): String {
+        return super.toString() + ": w $width x h $height"
+    }
+}
+
+/**
+ * The angles (in radians) representing the sides of the view frustum. These are not expected to
+ * change over the lifetime of the session but in rare cases may change due to updated camera
+ * settings.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public data class Fov(
+    public val angleLeft: Float,
+    public val angleRight: Float,
+    public val angleUp: Float,
+    public val angleDown: Float,
+)
+
+/** Type of plane based on orientation i.e. Horizontal or Vertical. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object PlaneType {
+    public const val HORIZONTAL: Int = 0
+    public const val VERTICAL: Int = 1
+    public const val ANY: Int = 2
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+@Retention(AnnotationRetention.SOURCE)
+@IntDef(PlaneType.HORIZONTAL, PlaneType.VERTICAL, PlaneType.ANY)
+@Target(AnnotationTarget.TYPE, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
+internal annotation class PlaneTypeValue
+
+/** Semantic plane types. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public object PlaneSemantic {
+    public const val WALL: Int = 0
+    public const val FLOOR: Int = 1
+    public const val CEILING: Int = 2
+    public const val TABLE: Int = 3
+    public const val ANY: Int = 4
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+@Retention(AnnotationRetention.SOURCE)
+@IntDef(
+    PlaneSemantic.WALL,
+    PlaneSemantic.FLOOR,
+    PlaneSemantic.CEILING,
+    PlaneSemantic.TABLE,
+    PlaneSemantic.ANY,
+)
+@Target(AnnotationTarget.TYPE, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
+internal annotation class PlaneSemanticValue
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Utils.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Utils.kt
new file mode 100644
index 0000000..efe9b46
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/Utils.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.os.Handler
+import android.os.Looper
+import androidx.xr.runtime.math.Ray
+import androidx.xr.scenecore.InputEvent.HitInfo
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions as RtDimensions
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent as RtInputEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo as RtHitInfo
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEvent as RtMoveEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions as RtPixelDimensions
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent as RtResizeEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities as RtSpatialCapabilities
+import java.util.concurrent.Executor
+
+internal class HandlerExecutor(val handler: Handler) : Executor {
+    override fun execute(command: Runnable) {
+        handler.post(command)
+    }
+
+    companion object {
+        val mainThreadExecutor: Executor = HandlerExecutor(Handler(Looper.getMainLooper()))
+    }
+}
+
+/** Extension function that converts a [Dimensions] to [RtDimensions]. */
+internal fun Dimensions.toRtDimensions(): RtDimensions {
+    return RtDimensions(width, height, depth)
+}
+
+/** Extension function that converts a [RtDimensions] to [Dimensions]. */
+internal fun RtDimensions.toDimensions(): Dimensions {
+    return Dimensions(width, height, depth)
+}
+
+/** Extension function that converts a [PixelDimensions] to [RtPixelDimensions]. */
+internal fun PixelDimensions.toRtPixelDimensions(): RtPixelDimensions {
+    return RtPixelDimensions(width, height)
+}
+
+/** Extension function that converts a [RtPixelDimensions] to [PixelDimensions]. */
+internal fun RtPixelDimensions.toPixelDimensions(): PixelDimensions {
+    return PixelDimensions(width, height)
+}
+
+/** Extension function that converts [Int] to [JxrPlatformAdapter.PlaneType]. */
+internal fun Int.toRtPlaneType(): JxrPlatformAdapter.PlaneType {
+    return when (this) {
+        PlaneType.HORIZONTAL -> JxrPlatformAdapter.PlaneType.HORIZONTAL
+        PlaneType.VERTICAL -> JxrPlatformAdapter.PlaneType.VERTICAL
+        PlaneType.ANY -> JxrPlatformAdapter.PlaneType.ANY
+        else -> error("Unknown Plane Type: $PlaneType")
+    }
+}
+
+/** Extension function that converts [Int] to [JxrPlatformAdapter.PlaneSemantic]. */
+internal fun Int.toRtPlaneSemantic(): JxrPlatformAdapter.PlaneSemantic {
+    return when (this) {
+        PlaneSemantic.WALL -> JxrPlatformAdapter.PlaneSemantic.WALL
+        PlaneSemantic.FLOOR -> JxrPlatformAdapter.PlaneSemantic.FLOOR
+        PlaneSemantic.CEILING -> JxrPlatformAdapter.PlaneSemantic.CEILING
+        PlaneSemantic.TABLE -> JxrPlatformAdapter.PlaneSemantic.TABLE
+        PlaneSemantic.ANY -> JxrPlatformAdapter.PlaneSemantic.ANY
+        else -> error("Unknown Plane Semantic: $PlaneSemantic")
+    }
+}
+
+/** Extension function that converts a [RtMoveEvent] to a [MoveEvent]. */
+internal fun RtMoveEvent.toMoveEvent(entityManager: EntityManager): MoveEvent {
+
+    disposedEntity?.let { entityManager.removeEntity(it) }
+    return MoveEvent(
+        moveState.toMoveState(),
+        Ray(initialInputRay.origin, initialInputRay.direction),
+        Ray(currentInputRay.origin, currentInputRay.direction),
+        previousPose,
+        currentPose,
+        previousScale.x,
+        currentScale.x,
+        entityManager.getEntityForRtEntity(initialParent)!!,
+        updatedParent?.let {
+            entityManager.getEntityForRtEntity(it)
+                ?: AnchorEntity.create(it as JxrPlatformAdapter.AnchorEntity, entityManager)
+        },
+    )
+}
+
+/** Extension function that converts a [RtHitInfo] to a [HitInfo]. */
+internal fun RtHitInfo.toHitInfo(entityManager: EntityManager): HitInfo? {
+    // TODO: b/377541143 - Replace instance equality check in EntityManager.
+    val hitEntity = inputEntity?.let { entityManager.getEntityForRtEntity(it) }
+    return if (hitEntity == null) {
+        null
+    } else {
+        HitInfo(inputEntity = hitEntity, hitPosition = hitPosition, transform = transform)
+    }
+}
+
+/** Extension function that converts a [RtInputEvent] to a [InputEvent]. */
+internal fun RtInputEvent.toInputEvent(entityManager: EntityManager): InputEvent {
+    return InputEvent(
+        source.toInputEventSource(),
+        pointerType.toInputEventPointerType(),
+        timestamp,
+        origin,
+        direction,
+        action.toInputEventAction(),
+        hitInfo?.toHitInfo(entityManager),
+        secondaryHitInfo?.toHitInfo(entityManager),
+    )
+}
+
+/** Extension function that converts a [RtSpatialCapabilities] to a [SpatialCapabilities]. */
+internal fun RtSpatialCapabilities.toSpatialCapabilities(): SpatialCapabilities {
+    return SpatialCapabilities(capabilities.toSpatialCapability())
+}
+
+/** Extension function that converts a [RtResizeEvent] to a [ResizeEvent]. */
+internal fun RtResizeEvent.toResizeEvent(): ResizeEvent {
+    return ResizeEvent(resizeState.toResizeState(), newSize.toDimensions())
+}
+
+/**
+ * Extension function that converts a [Set] of [AnchorPlacement] to a [Set] of
+ * [JxrPlatformAdapter.AnchorPlacement].
+ */
+internal fun Set<AnchorPlacement>.toRtAnchorPlacement(
+    runtime: JxrPlatformAdapter
+): Set<JxrPlatformAdapter.AnchorPlacement> {
+    val rtAnchorPlacementSet = HashSet<JxrPlatformAdapter.AnchorPlacement>()
+    for (placement in this) {
+        val planeTypeFilter = placement.planeTypeFilter.map { it.toRtPlaneType() }.toMutableSet()
+        val planeSemanticFilter =
+            placement.planeSemanticFilter.map { it.toRtPlaneSemantic() }.toMutableSet()
+
+        val rtAnchorPlacement =
+            runtime.createAnchorPlacementForPlanes(planeTypeFilter, planeSemanticFilter)
+        rtAnchorPlacementSet.add(rtAnchorPlacement)
+    }
+    return rtAnchorPlacementSet
+}
+
+/** Extension function that converts a [Int] to [MoveEvent.MoveState]. */
[email protected]
+internal fun Int.toMoveState(): Int {
+    return when (this) {
+        JxrPlatformAdapter.MoveEvent.MOVE_STATE_START -> MoveEvent.MOVE_STATE_START
+        JxrPlatformAdapter.MoveEvent.MOVE_STATE_ONGOING -> MoveEvent.MOVE_STATE_ONGOING
+        JxrPlatformAdapter.MoveEvent.MOVE_STATE_END -> MoveEvent.MOVE_STATE_END
+        else -> error("Unknown Move State: $this")
+    }
+}
+
+/** Extension function that converts a [Int] to [ResizeEvent.ResizeState]. */
[email protected]
+internal fun Int.toResizeState(): Int {
+    return when (this) {
+        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_UNKNOWN -> ResizeEvent.RESIZE_STATE_UNKNOWN
+        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START -> ResizeEvent.RESIZE_STATE_START
+        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_ONGOING -> ResizeEvent.RESIZE_STATE_ONGOING
+        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_END -> ResizeEvent.RESIZE_STATE_END
+        else -> error("Unknown Resize State: $this")
+    }
+}
+
+/** Extension function that converts a [Int] to [InputEvent.Source]. */
[email protected]
+internal fun Int.toInputEventSource(): Int {
+    return when (this) {
+        JxrPlatformAdapter.InputEvent.SOURCE_UNKNOWN -> InputEvent.SOURCE_UNKNOWN
+        JxrPlatformAdapter.InputEvent.SOURCE_HEAD -> InputEvent.SOURCE_HEAD
+        JxrPlatformAdapter.InputEvent.SOURCE_CONTROLLER -> InputEvent.SOURCE_CONTROLLER
+        JxrPlatformAdapter.InputEvent.SOURCE_HANDS -> InputEvent.SOURCE_HANDS
+        JxrPlatformAdapter.InputEvent.SOURCE_MOUSE -> InputEvent.SOURCE_MOUSE
+        JxrPlatformAdapter.InputEvent.SOURCE_GAZE_AND_GESTURE -> InputEvent.SOURCE_GAZE_AND_GESTURE
+        else -> error("Unknown Input Event Source: $this")
+    }
+}
+
+/** Extension function that converts a [Int] to [InputEvent.PointerType]. */
[email protected]
+internal fun Int.toInputEventPointerType(): Int {
+    return when (this) {
+        JxrPlatformAdapter.InputEvent.POINTER_TYPE_DEFAULT -> InputEvent.POINTER_TYPE_DEFAULT
+        JxrPlatformAdapter.InputEvent.POINTER_TYPE_LEFT -> InputEvent.POINTER_TYPE_LEFT
+        JxrPlatformAdapter.InputEvent.POINTER_TYPE_RIGHT -> InputEvent.POINTER_TYPE_RIGHT
+        else -> error("Unknown Input Event Pointer Type: $this")
+    }
+}
+
+/** Extension function that converts a [Int] to [SpatialCapabilities.SpatialCapability]. */
[email protected]
+internal fun Int.toSpatialCapability(): Int {
+    return this
+}
+
+/** Extension function that converts a [Int] to [InputEvent.Action]. */
[email protected]
+internal fun Int.toInputEventAction(): Int {
+    return when (this) {
+        JxrPlatformAdapter.InputEvent.ACTION_DOWN -> InputEvent.ACTION_DOWN
+        JxrPlatformAdapter.InputEvent.ACTION_UP -> InputEvent.ACTION_UP
+        JxrPlatformAdapter.InputEvent.ACTION_MOVE -> InputEvent.ACTION_MOVE
+        JxrPlatformAdapter.InputEvent.ACTION_CANCEL -> InputEvent.ACTION_CANCEL
+        JxrPlatformAdapter.InputEvent.ACTION_HOVER_MOVE -> InputEvent.ACTION_HOVER_MOVE
+        JxrPlatformAdapter.InputEvent.ACTION_HOVER_ENTER -> InputEvent.ACTION_HOVER_ENTER
+        JxrPlatformAdapter.InputEvent.ACTION_HOVER_EXIT -> InputEvent.ACTION_HOVER_EXIT
+        else -> error("Unknown Input Event Action: $this")
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseActivityPose.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseActivityPose.java
new file mode 100644
index 0000000..050df03
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseActivityPose.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.common;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose;
+
+/**
+ * Base implementation of JXRCore ActivityPose.
+ *
+ * <p>A ActivityPose is an object that has a pose in the world space.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class BaseActivityPose implements ActivityPose {
+    @Override
+    @NonNull
+    public Pose getActivitySpacePose() {
+        throw new UnsupportedOperationException(
+                "getActivitySpacePose is not implemented for this ActivityPose.");
+    }
+
+    /** Returns the pose for this entity, relative to the activity space root. */
+    @NonNull
+    public Pose getPoseInActivitySpace() {
+        throw new UnsupportedOperationException(
+                "getPoseInActivitySpace is not implemented for this ActivityPose.");
+    }
+
+    @Override
+    @NonNull
+    public Vector3 getWorldSpaceScale() {
+        return new Vector3(1f, 1f, 1f);
+    }
+
+    @Override
+    @NonNull
+    public Vector3 getActivitySpaceScale() {
+        throw new UnsupportedOperationException(
+                "getActivitySpaceScale is not implemented for this ActivityPose.");
+    }
+
+    @Override
+    @NonNull
+    public Pose transformPoseTo(@NonNull Pose pose, @NonNull ActivityPose destination) {
+
+        // TODO: b/355680575 - Revisit if we need to account for parent rotation when calculating
+        // the
+        // scale. This code might produce unexpected results when non-uniform scale is involved in
+        // the
+        // parent-child entity hierarchy.
+
+        // Compute the inverse scale of the destination entity in the activity space.
+
+        // We assume that the world space root is the activity space root.
+        BaseActivityPose baseDestination = (BaseActivityPose) destination;
+        Vector3 destinationScale = baseDestination.getWorldSpaceScale();
+
+        Vector3 inverseDestinationScale =
+                new Vector3(
+                        1f / destinationScale.getX(),
+                        1f / destinationScale.getY(),
+                        1f / destinationScale.getZ());
+
+        // Compute the transformation to the destination entity from this local entity.
+        Pose activityToLocal = this.getPoseInActivitySpace();
+        Pose activityToDestination = baseDestination.getPoseInActivitySpace();
+        Pose destinationToActivity =
+                new Pose(
+                                activityToDestination
+                                        .getTranslation()
+                                        .times(inverseDestinationScale),
+                                activityToDestination.getRotation())
+                        .getInverse();
+        Pose destinationToLocal =
+                destinationToActivity.compose(
+                        new Pose(
+                                activityToLocal.getTranslation().times(inverseDestinationScale),
+                                activityToLocal.getRotation()));
+
+        // Apply the transformation to the destination entity, from this entity, on the local pose.
+        return destinationToLocal.compose(
+                new Pose(
+                        pose.getTranslation()
+                                .times(this.getWorldSpaceScale().times(inverseDestinationScale)),
+                        pose.getRotation()));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseEntity.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseEntity.java
new file mode 100644
index 0000000..c2584bc
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/common/BaseEntity.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.common;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.Component;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Implementation of a subset of core RealityCore Entity functionality. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public abstract class BaseEntity extends BaseActivityPose implements Entity {
+    private final List<Entity> children = new ArrayList<>();
+    private final List<Component> componentList = new ArrayList<>();
+    private BaseEntity parent;
+    private Pose pose = new Pose();
+    private Vector3 scale = new Vector3(1.0f, 1.0f, 1.0f);
+    private float alpha = 1.0f;
+    private boolean hidden = false;
+
+    protected void addChildInternal(@NonNull Entity child) {
+        if (children.contains(child)) {
+            Log.w("RealityCoreRuntime", "Trying to add child who is already a child.");
+        }
+        children.add(child);
+    }
+
+    protected void removeChildInternal(@NonNull Entity child) {
+        if (!children.contains(child)) {
+            Log.w("RealityCoreRuntime", "Trying to remove child who is not a child.");
+            return;
+        }
+        children.remove(child);
+    }
+
+    @Override
+    public void addChild(@NonNull Entity child) {
+        child.setParent(this);
+    }
+
+    @Override
+    public void addChildren(@NonNull List<Entity> children) {
+        for (Entity child : children) {
+            child.setParent(this);
+        }
+    }
+
+    @Override
+    @Nullable
+    public Entity getParent() {
+        return parent;
+    }
+
+    @Override
+    public void setParent(@Nullable Entity parent) {
+        if ((parent != null) && !(parent instanceof BaseEntity)) {
+            Log.e("RealityCoreRuntime", "Cannot set non-BaseEntity as a parent of a BaseEntity");
+            return;
+        }
+        if (this.parent != null) {
+            this.parent.removeChildInternal(this);
+        }
+        this.parent = (BaseEntity) parent;
+        if (this.parent != null) {
+            this.parent.addChildInternal(this);
+        }
+    }
+
+    @Override
+    @NonNull
+    public List<Entity> getChildren() {
+        return children;
+    }
+
+    @Override
+    public void setContentDescription(@NonNull String text) {
+        // TODO(b/320202321): Finish A11y Text integration.
+        Log.i("BaseEntity", "setContentDescription: " + text);
+    }
+
+    @Override
+    @NonNull
+    public Pose getPose() {
+        return pose;
+    }
+
+    @Override
+    public void setPose(@NonNull Pose pose) {
+        this.pose = pose;
+    }
+
+    @Override
+    @NonNull
+    public Pose getActivitySpacePose() {
+        // Any parentless "space" entities (such as the root and anchor entities) are expected to
+        // override this method non-recursively so that this error is never thrown.
+        if (parent == null) {
+            throw new IllegalStateException("Cannot get pose in ActivitySpace with a null parent");
+        }
+
+        return parent.getActivitySpacePose()
+                .compose(
+                        new Pose(
+                                this.pose.getTranslation().times(parent.getWorldSpaceScale()),
+                                this.pose.getRotation()));
+    }
+
+    @Override
+    @NonNull
+    public Vector3 getScale() {
+        return scale;
+    }
+
+    @Override
+    public void setScale(@NonNull Vector3 scale) {
+        this.scale = scale;
+    }
+
+    // Purely sets the value of the scale.
+    protected final void setScaleInternal(@NonNull Vector3 scale) {
+        this.scale = scale;
+    }
+
+    @Override
+    public float getAlpha() {
+        return alpha;
+    }
+
+    @Override
+    public void setAlpha(float alpha) {
+        this.alpha = alpha;
+    }
+
+    @Override
+    public float getActivitySpaceAlpha() {
+        if (parent == null) {
+            return alpha;
+        }
+        return parent.getActivitySpaceAlpha() * alpha;
+    }
+
+    @Override
+    @NonNull
+    public Vector3 getWorldSpaceScale() {
+        if (parent == null) {
+            throw new IllegalStateException("Cannot get scale in WorldSpace with a null parent");
+        }
+        return parent.getWorldSpaceScale().times(this.scale);
+    }
+
+    @Override
+    @NonNull
+    public Vector3 getActivitySpaceScale() {
+        if (parent == null) {
+            throw new IllegalStateException("Cannot get scale in ActivitySpace with a null parent");
+        }
+        return parent.getActivitySpaceScale().times(this.scale);
+    }
+
+    @Override
+    public boolean isHidden(boolean includeParents) {
+        if (!includeParents || parent == null) {
+            return hidden;
+        }
+        return hidden || parent.isHidden(true);
+    }
+
+    @Override
+    public void setHidden(boolean hidden) {
+        this.hidden = hidden;
+    }
+
+    @Override
+    public void dispose() {
+        // Create a copy to avoid concurrent modification issues since the children detach
+        // themselves
+        // from their parents as they are disposed.
+        List<Entity> childrenToDispose = new ArrayList<>(children);
+        for (Entity child : childrenToDispose) {
+            child.dispose();
+        }
+    }
+
+    @Override
+    public boolean addComponent(@NonNull Component component) {
+        if (component.onAttach(this)) {
+            componentList.add(component);
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void removeComponent(@NonNull Component component) {
+        if (componentList.contains(component)) {
+            component.onDetach(this);
+            componentList.remove(component);
+        }
+    }
+
+    @Override
+    public void removeAllComponents() {
+        for (Component component : componentList) {
+            component.onDetach(this);
+        }
+        componentList.clear();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivityPanelEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivityPanelEntityImpl.java
new file mode 100644
index 0000000..3da6432
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivityPanelEntityImpl.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.space.ActivityPanel;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivityPanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+
+import java.util.Objects;
+import java.util.concurrent.ScheduledExecutorService;
+
+/** Implementation of {@link ActivityPanelEntity}. */
+class ActivityPanelEntityImpl extends BasePanelEntity implements ActivityPanelEntity {
+    private static final String TAG = ActivityPanelEntityImpl.class.getSimpleName();
+    private final ActivityPanel activityPanel;
+
+    // TODO(b/352630140): Add a static factory method and remove the business logic from
+    //                    JxrPlatformAdapterAxr.
+
+    ActivityPanelEntityImpl(
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ActivityPanel activityPanel,
+            PixelDimensions windowBoundsPx,
+            ScheduledExecutorService executor) {
+        super(node, extensions, entityManager, executor);
+        // We need to notify our base class of the pixelDimensions, even though the Extensions are
+        // initialized in the factory method. (ext.ActivityPanel.setWindowBounds, etc)
+        super.setPixelDimensions(windowBoundsPx);
+        this.activityPanel = activityPanel;
+    }
+
+    @Override
+    public void launchActivity(Intent intent, @Nullable Bundle bundle) {
+        // Note that launching an Activity into the Panel doesn't actually update the size. The
+        // application is expected to set the size of the ActivityPanel at construction time, before
+        // launching an Activity into it. The Activity will then render into the size the
+        // application
+        // specified, and the system will apply letterboxing if necessary.
+        activityPanel.launchActivity(intent, bundle);
+    }
+
+    @Override
+    public void moveActivity(Activity activity) {
+        // Note that moving an Activity into the Panel doesn't actually update the size. The
+        // application
+        // should explicitly call setPixelDimensions() to update the size of an ActivityPanel.
+        activityPanel.moveActivity(activity);
+    }
+
+    @Override
+    public void setPixelDimensions(PixelDimensions dimensions) {
+        PixelDimensions oldDimensions = this.pixelDimensions;
+        super.setPixelDimensions(dimensions);
+
+        // Avoid updating the bounds if we were called with the same values.
+        if (Objects.equals(oldDimensions, dimensions)) {
+            Log.i(TAG, "setPixelDimensions called with same dimensions - " + dimensions);
+            return;
+        }
+
+        activityPanel.setWindowBounds(new Rect(0, 0, dimensions.width, dimensions.height));
+    }
+
+    /**
+     * Disposes the ActivityPanelEntity.
+     *
+     * <p>This will delete the ActivityPanel and destroy the embedded activity.
+     */
+    @Override
+    public void dispose() {
+        activityPanel.delete();
+        super.dispose();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivitySpaceImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivitySpaceImpl.java
new file mode 100644
index 0000000..a959d70
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ActivitySpaceImpl.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.space.Bounds;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+/**
+ * Implementation of a RealityCore ActivitySpaceImpl.
+ *
+ * <p>This is used to create an entity that contains the task node.
+ */
+@SuppressWarnings({"deprecation", "UnnecessarilyFullyQualified"}) // TODO(b/373435470): Remove
+final class ActivitySpaceImpl extends SystemSpaceEntityImpl implements ActivitySpace {
+
+    private static final String TAG = "ActivitySpaceImpl";
+
+    private final Set<OnBoundsChangedListener> boundsListeners =
+            Collections.synchronizedSet(new HashSet<>());
+
+    private final Supplier<SpatialState> spatialStateProvider;
+    private final AtomicReference<Dimensions> bounds = new AtomicReference<>();
+
+    public ActivitySpaceImpl(
+            Node taskNode,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            Supplier<SpatialState> spatialStateProvider,
+            ScheduledExecutorService executor) {
+        super(taskNode, extensions, entityManager, executor);
+
+        this.spatialStateProvider = spatialStateProvider;
+    }
+
+    /** Returns the identity pose since this entity defines the origin of the activity space. */
+    @Override
+    public Pose getPoseInActivitySpace() {
+        return new Pose();
+    }
+
+    /** Returns the identity pose since we assume the activity space is the world space root. */
+    @Override
+    public Pose getActivitySpacePose() {
+
+        return new Pose();
+    }
+
+    @Override
+    public Vector3 getActivitySpaceScale() {
+        return new Vector3(1.0f, 1.0f, 1.0f);
+    }
+
+    @Override
+    public void setParent(Entity parent) {
+        Log.e(TAG, "Cannot set parent for the ActivitySpace.");
+    }
+
+    @Override
+    public void setScale(Vector3 scale) {
+        // TODO(b/349391097): make this behavior consistent with AnchorEntityImpl
+        Log.e(TAG, "Cannot set scale for the ActivitySpace.");
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        Log.i(TAG, "Disposing " + this);
+        super.dispose();
+    }
+
+    @Override
+    public Dimensions getBounds() {
+        // The bounds are kept in sync with the Extensions in the onBoundsChangedEvent callback. We
+        // only
+        // invoke getSpatialState if they've never been set.
+        return bounds.updateAndGet(
+                oldBounds -> {
+                    if (oldBounds == null) {
+                        Bounds bounds = spatialStateProvider.get().getBounds();
+                        return new Dimensions(bounds.width, bounds.height, bounds.depth);
+                    }
+                    return oldBounds;
+                });
+    }
+
+    @Override
+    public void addOnBoundsChangedListener(@NonNull OnBoundsChangedListener listener) {
+        this.boundsListeners.add(listener);
+    }
+
+    @Override
+    public void removeOnBoundsChangedListener(@NonNull OnBoundsChangedListener listener) {
+        this.boundsListeners.remove(listener);
+    }
+
+    /**
+     * This method is called by the Runtime when the bounds of the Activity change. We dispatch the
+     * event upwards to the JXRCoreSession via ActivitySpace.
+     *
+     * <p>Note that this call happens on the Activity's UI thread, so we should be careful not to
+     * block it.
+     */
+    public void onBoundsChanged(Bounds newBounds) {
+        Dimensions newDimensions =
+                bounds.updateAndGet(
+                        oldBounds ->
+                                new Dimensions(newBounds.width, newBounds.height, newBounds.depth));
+        for (OnBoundsChangedListener listener : boundsListeners) {
+            listener.onBoundsChanged(newDimensions);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorEntityImpl.java
new file mode 100644
index 0000000..8ab6970
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorEntityImpl.java
@@ -0,0 +1,652 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.runtime.openxr.ExportableAnchor;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.OnStateChangedListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.impl.perception.Anchor;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.impl.perception.Plane.PlaneData;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+
+/**
+ * Implementation of AnchorEntity.
+ *
+ * <p>This entity creates trackable anchors in space.
+ */
+@SuppressWarnings("BanSynchronizedMethods")
+class AnchorEntityImpl extends SystemSpaceEntityImpl implements AnchorEntity {
+    public static final Duration ANCHOR_SEARCH_DELAY = Duration.ofMillis(500);
+    public static final Duration PERSIST_STATE_CHECK_DELAY = Duration.ofMillis(500);
+    public static final String ANCHOR_NODE_NAME = "AnchorNode";
+    private static final String TAG = "AnchorEntityImpl";
+    private final ActivitySpaceImpl activitySpace;
+    private final AndroidXrEntity activitySpaceRoot;
+    private final PerceptionLibrary perceptionLibrary;
+    private OnStateChangedListener onStateChangedListener;
+    private State state = State.UNANCHORED;
+    private PersistState persistState = PersistState.PERSIST_NOT_REQUESTED;
+    private Anchor anchor;
+    private UUID uuid = null;
+    private PersistStateChangeListener persistStateChangeListener;
+
+    private static class AnchorCreationData {
+
+        static final int ANCHOR_CREATION_SEMANTIC = 1;
+        static final int ANCHOR_CREATION_PERSISTED = 2;
+        static final int ANCHOR_CREATION_PLANE = 3;
+        static final int ANCHOR_CREATION_PERCEPTION_ANCHOR = 4;
+
+        @AnchorCreationType int anchorCreationType;
+
+        /** IntDef for Anchor creation types. */
+        @IntDef({
+            ANCHOR_CREATION_SEMANTIC,
+            ANCHOR_CREATION_PERSISTED,
+            ANCHOR_CREATION_PLANE,
+            ANCHOR_CREATION_PERCEPTION_ANCHOR,
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        private @interface AnchorCreationType {}
+
+        // Anchor that is already created via Perception API.
+        androidx.xr.arcore.Anchor perceptionAnchor;
+
+        // Anchor search deadline for semantic and persisted anchors.
+        Long anchorSearchDeadline;
+
+        // Fields exclusively for semantic anchors.
+        Dimensions dimensions;
+        PlaneType planeType;
+        PlaneSemantic planeSemantic;
+
+        // Fields exclusively for persisted anchors.
+        UUID uuid = null;
+
+        // Fields exclusively for plane anchors.
+        Plane plane;
+        Pose planeOffsetPose;
+        Long planeDataTimeNs;
+    }
+
+    static AnchorEntityImpl createSemanticAnchor(
+            Node node,
+            Dimensions dimensions,
+            PlaneType planeType,
+            PlaneSemantic planeSemantic,
+            Duration anchorSearchTimeout,
+            ActivitySpace activitySpace,
+            Entity activitySpaceRoot,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            PerceptionLibrary perceptionLibrary) {
+        AnchorCreationData anchorCreationData = new AnchorCreationData();
+        anchorCreationData.anchorCreationType = AnchorCreationData.ANCHOR_CREATION_SEMANTIC;
+        anchorCreationData.dimensions = dimensions;
+        anchorCreationData.planeType = planeType;
+        anchorCreationData.planeSemantic = planeSemantic;
+        anchorCreationData.anchorSearchDeadline = getAnchorDeadline(anchorSearchTimeout);
+        return new AnchorEntityImpl(
+                node,
+                anchorCreationData,
+                activitySpace,
+                activitySpaceRoot,
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    static AnchorEntityImpl createPersistedAnchor(
+            Node node,
+            UUID uuid,
+            Duration anchorSearchTimeout,
+            ActivitySpace activitySpace,
+            Entity activitySpaceRoot,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            PerceptionLibrary perceptionLibrary) {
+        AnchorCreationData anchorCreationData = new AnchorCreationData();
+        anchorCreationData.anchorCreationType = AnchorCreationData.ANCHOR_CREATION_PERSISTED;
+        anchorCreationData.uuid = uuid;
+        anchorCreationData.anchorSearchDeadline = getAnchorDeadline(anchorSearchTimeout);
+        return new AnchorEntityImpl(
+                node,
+                anchorCreationData,
+                activitySpace,
+                activitySpaceRoot,
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    static AnchorEntityImpl createAnchorFromPlane(
+            Node node,
+            Plane plane,
+            Pose planeOffsetPose,
+            @Nullable Long planeDataTimeNs,
+            ActivitySpace activitySpace,
+            Entity activitySpaceRoot,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            PerceptionLibrary perceptionLibrary) {
+        AnchorCreationData anchorCreationData = new AnchorCreationData();
+        anchorCreationData.anchorCreationType = AnchorCreationData.ANCHOR_CREATION_PLANE;
+        anchorCreationData.plane = plane;
+        anchorCreationData.planeOffsetPose = planeOffsetPose;
+        anchorCreationData.planeDataTimeNs = planeDataTimeNs;
+        return new AnchorEntityImpl(
+                node,
+                anchorCreationData,
+                activitySpace,
+                activitySpaceRoot,
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    static AnchorEntityImpl createAnchorFromPerceptionAnchor(
+            Node node,
+            androidx.xr.arcore.Anchor anchor,
+            ActivitySpace activitySpace,
+            Entity activitySpaceRoot,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            PerceptionLibrary perceptionLibrary) {
+        AnchorCreationData anchorCreationData = new AnchorCreationData();
+        anchorCreationData.anchorCreationType =
+                AnchorCreationData.ANCHOR_CREATION_PERCEPTION_ANCHOR;
+        anchorCreationData.perceptionAnchor = anchor;
+        return new AnchorEntityImpl(
+                node,
+                anchorCreationData,
+                activitySpace,
+                activitySpaceRoot,
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    protected AnchorEntityImpl(
+            Node node,
+            AnchorCreationData anchorCreationData,
+            ActivitySpace activitySpace,
+            Entity activitySpaceRoot,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            PerceptionLibrary perceptionLibrary) {
+        super(node, extensions, entityManager, executor);
+        this.perceptionLibrary = perceptionLibrary;
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setName(node, ANCHOR_NODE_NAME).apply();
+        }
+
+        if (activitySpace instanceof ActivitySpaceImpl) {
+            this.activitySpace = (ActivitySpaceImpl) activitySpace;
+        } else {
+            Log.e(
+                    TAG,
+                    "ActivitySpace is not an instance of ActivitySpaceImpl.Anchor is in Error"
+                            + " state.");
+            this.state = State.ERROR;
+            this.activitySpace = null;
+        }
+
+        if (activitySpaceRoot instanceof AndroidXrEntity) {
+            this.activitySpaceRoot = (AndroidXrEntity) activitySpaceRoot;
+        } else {
+            Log.e(
+                    TAG,
+                    "ActivitySpaceRoot is not an instance of AndroidXrEntity. Anchor is in Error"
+                            + " state.");
+            this.state = State.ERROR;
+            this.activitySpaceRoot = null;
+        }
+
+        // Return early if the state is already in an error state.
+        if (this.state == State.ERROR) {
+            return;
+        }
+
+        // If we are creating a semantic or persisted anchor then we need to search for the anchor
+        // asynchronously. Otherwise we can create the anchor on the plane.
+        if (anchorCreationData.anchorCreationType
+                == AnchorCreationData.ANCHOR_CREATION_PERCEPTION_ANCHOR) {
+            tryConvertAnchor(anchorCreationData.perceptionAnchor);
+        } else if (anchorCreationData.anchorCreationType
+                        == AnchorCreationData.ANCHOR_CREATION_SEMANTIC
+                || anchorCreationData.anchorCreationType
+                        == AnchorCreationData.ANCHOR_CREATION_PERSISTED) {
+            tryFindAnchor(anchorCreationData);
+        } else if (anchorCreationData.anchorCreationType
+                == AnchorCreationData.ANCHOR_CREATION_PLANE) {
+            tryCreateAnchorForPlane(anchorCreationData);
+        }
+    }
+
+    @Nullable
+    private static Long getAnchorDeadline(Duration anchorSearchTimeout) {
+        // If the timeout is zero or null then we return null here and the anchor search will
+        // continue
+        // indefinitely.
+        if (anchorSearchTimeout == null || anchorSearchTimeout.isZero()) {
+            return null;
+        }
+        return SystemClock.uptimeMillis() + anchorSearchTimeout.toMillis();
+    }
+
+    // Converts a perception anchor to JXRCore runtime anchor.
+    private void tryConvertAnchor(androidx.xr.arcore.Anchor perceptionAnchor) {
+        ExportableAnchor exportableAnchor = (ExportableAnchor) perceptionAnchor.getRuntimeAnchor();
+        this.anchor =
+                new Anchor(exportableAnchor.getNativePointer(), exportableAnchor.getAnchorToken());
+        if (anchor.getAnchorToken() == null) {
+            updateState(State.ERROR);
+            return;
+        }
+        updateState(State.ANCHORED);
+    }
+
+    // Creates an anchor on the provided plane.
+    private void tryCreateAnchorForPlane(AnchorCreationData anchorCreationData) {
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                RuntimeUtils.poseToPerceptionPose(anchorCreationData.planeOffsetPose);
+        anchor =
+                anchorCreationData.plane.createAnchor(
+                        perceptionPose, anchorCreationData.planeDataTimeNs);
+        if (anchor == null || anchor.getAnchorToken() == null) {
+            updateState(State.ERROR);
+            return;
+        }
+        updateState(State.ANCHORED);
+    }
+
+    // Schedules a search for the anchor.
+    private void scheduleTryFindAnchor(AnchorCreationData anchorCreationData) {
+        ScheduledFuture<?> unusedAnchorFuture =
+                executor.schedule(
+                        () -> tryFindAnchor(anchorCreationData),
+                        ANCHOR_SEARCH_DELAY.toMillis(),
+                        MILLISECONDS);
+    }
+
+    // Checks if the anchor search has exceeded the deadline.
+    private boolean searchDeadlineExceeded(Long anchorSearchDeadline) {
+        // If the system is paused it will continue to count after it wakes up.
+        return anchorSearchDeadline != null && SystemClock.uptimeMillis() > anchorSearchDeadline;
+    }
+
+    private synchronized void cancelAnchorSearch() {
+        if (state == State.UNANCHORED) {
+            Log.i(TAG, "Stopping search for anchor, reached timeout.");
+            updateState(State.TIMED_OUT);
+        }
+    }
+
+    // Searches for the anchor and updates the state based on the result. If the anchor wasn't found
+    // then the search is scheduled again if the deadline has not been exceeded.
+    private void tryFindAnchor(AnchorCreationData anchorCreationData) {
+        if (this.activitySpace == null) {
+            Log.e(TAG, "Skipping search for anchor there is no valid parent.");
+            return;
+        }
+        synchronized (this) {
+            if (state != State.UNANCHORED) {
+                // This should only be searching for an anchor if the state is UNANCHORED. If the
+                // state is
+                // ANCHORED then the anchor was already found, if it is ERROR then the entity no
+                // longer can
+                // use the anchor. Return here to stop the search.
+                Log.i(TAG, "Stopping search for anchor, the state is: " + state);
+                return;
+            }
+        }
+        // Check if we are passed the deadline if so, cancel the search.
+        if (searchDeadlineExceeded(anchorCreationData.anchorSearchDeadline)) {
+            cancelAnchorSearch();
+            return;
+        }
+
+        if (perceptionLibrary.getSession() == null) {
+            scheduleTryFindAnchor(anchorCreationData);
+            return;
+        }
+
+        if (anchorCreationData.anchorCreationType == AnchorCreationData.ANCHOR_CREATION_SEMANTIC) {
+            anchor = findPlaneAnchor(anchorCreationData);
+        } else if (anchorCreationData.anchorCreationType
+                == AnchorCreationData.ANCHOR_CREATION_PERSISTED) {
+            anchor = perceptionLibrary.getSession().createAnchorFromUuid(anchorCreationData.uuid);
+        } else {
+            Log.e(
+                    TAG,
+                    "Searching for anchor creation type is not supported: "
+                            + anchorCreationData.anchorCreationType);
+        }
+
+        if (anchor == null || anchor.getAnchorToken() == null) {
+            scheduleTryFindAnchor(anchorCreationData);
+            return;
+        }
+        Log.i(TAG, "Received anchor: " + anchor.getAnchorToken());
+        // TODO: b/330933143 - Handle Additional anchor states (e.g. Error/ Becoming unanchored)
+        synchronized (this) {
+            // Make sure that we are still looking for the anchor before updating the state. The
+            // application might have closed or disposed of the AnchorEntity while the search was
+            // still
+            // active on another thread.
+            if (state != State.UNANCHORED
+                    || searchDeadlineExceeded(anchorCreationData.anchorSearchDeadline)) {
+                Log.i(TAG, "Found anchor but no longer searching.");
+                if (searchDeadlineExceeded(anchorCreationData.anchorSearchDeadline)) {
+                    cancelAnchorSearch();
+                }
+                // Detach the found anchor since it is no longer needed.
+                if (!anchor.detach()) {
+                    Log.e(TAG, "Error when detaching anchor.");
+                }
+                return;
+            }
+            updateState(State.ANCHORED);
+            if (anchorCreationData.anchorCreationType
+                    == AnchorCreationData.ANCHOR_CREATION_PERSISTED) {
+                this.uuid = anchorCreationData.uuid;
+                updatePersistState(PersistState.PERSISTED);
+            }
+        }
+    }
+
+    // Tries to find a plane that matches the semantic anchor requirements. This creates an anchor
+    // on
+    // the plane if found.
+    @Nullable
+    private Anchor findPlaneAnchor(AnchorCreationData anchorCreationData) {
+        for (Plane plane : perceptionLibrary.getSession().getAllPlanes()) {
+            long timeNow = SystemClock.uptimeMillis() * 1000000;
+            PlaneData planeData = plane.getData(timeNow);
+            if (planeData == null) {
+                Log.e(TAG, "Plane data is null for plane");
+                continue;
+            }
+            Log.i(
+                    TAG,
+                    "Found a matching plane with Extent Width: "
+                            + planeData.extentWidth
+                            + ", Extent Height: "
+                            + planeData.extentHeight
+                            + ", Type: "
+                            + planeData.type
+                            + ", Label: "
+                            + planeData.label);
+            Plane.Type perceptionType = RuntimeUtils.getPlaneType(anchorCreationData.planeType);
+            Plane.Label perceptionLabel =
+                    RuntimeUtils.getPlaneLabel(anchorCreationData.planeSemantic);
+            if (anchorCreationData.dimensions.width <= planeData.extentWidth
+                    && anchorCreationData.dimensions.height <= planeData.extentHeight
+                    && (planeData.type == perceptionType || perceptionType == Plane.Type.ARBITRARY)
+                    && (planeData.label == perceptionLabel
+                            || perceptionLabel == Plane.Label.UNKNOWN)) {
+                return plane.createAnchor(
+                        androidx.xr.scenecore.impl.perception.Pose.identity(), timeNow);
+            }
+        }
+        return null;
+    }
+
+    private synchronized void updateState(State newState) {
+        if (state == newState) {
+            return;
+        }
+        state = newState;
+        if (state == State.ANCHORED) {
+            try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+                // Attach to the root CPM node. This will enable the anchored content to be visible.
+                // Note
+                // that the parent of the Entity is null, but the CPM Node is still attached.
+                transaction
+                        .setParent(node, activitySpace.getNode())
+                        .setAnchorId(node, anchor.getAnchorToken())
+                        .apply();
+            }
+        }
+        if (onStateChangedListener != null) {
+            onStateChangedListener.onStateChanged(state);
+        }
+    }
+
+    @Override
+    public State getState() {
+        return state;
+    }
+
+    @Override
+    public void setOnStateChangedListener(OnStateChangedListener onStateChangedListener) {
+        this.onStateChangedListener = onStateChangedListener;
+    }
+
+    @Override
+    @Nullable
+    public UUID persist() {
+        if (uuid != null) {
+            return uuid;
+        }
+        if (state != State.ANCHORED) {
+            Log.e(TAG, "Cannot persist an anchor that is not in the ANCHORED state.");
+            return null;
+        }
+        uuid = anchor.persist();
+        if (uuid == null) {
+            Log.e(TAG, "Failed to get a UUID for the anchor.");
+            return null;
+        }
+        updatePersistState(PersistState.PERSIST_PENDING);
+        schedulePersistStateCheck();
+        return uuid;
+    }
+
+    private void schedulePersistStateCheck() {
+        ScheduledFuture<?> unusedPersistStateFuture =
+                executor.schedule(
+                        this::checkPersistState,
+                        PERSIST_STATE_CHECK_DELAY.toMillis(),
+                        MILLISECONDS);
+    }
+
+    private void checkPersistState() {
+        synchronized (this) {
+            if (anchor == null) {
+                Log.i(
+                        TAG,
+                        "Anchor is disposed before becoming persisted, stop checking its persist"
+                                + " state.");
+                return;
+            }
+            if (anchor.getPersistState() == Anchor.PersistState.PERSISTED) {
+                updatePersistState(PersistState.PERSISTED);
+                Log.i(TAG, "Anchor is persisted.");
+                return;
+            }
+        }
+        schedulePersistStateCheck();
+    }
+
+    @Override
+    public void registerPersistStateChangeListener(
+            PersistStateChangeListener persistStateChangeListener) {
+        this.persistStateChangeListener = persistStateChangeListener;
+    }
+
+    private synchronized void updatePersistState(PersistState newPersistState) {
+        if (persistState == newPersistState) {
+            return;
+        }
+        persistState = newPersistState;
+        if (persistStateChangeListener != null) {
+            persistStateChangeListener.onPersistStateChanged(newPersistState);
+        }
+    }
+
+    @Override
+    public PersistState getPersistState() {
+        return persistState;
+    }
+
+    @Override
+    public long nativePointer() {
+        return anchor.getAnchorId();
+    }
+
+    @Override
+    public Pose getPose() {
+        throw new UnsupportedOperationException("Cannot get 'pose' on an AnchorEntity.");
+    }
+
+    @Override
+    public void setPose(Pose pose) {
+        throw new UnsupportedOperationException("Cannot set 'pose' on an AnchorEntity.");
+    }
+
+    @Override
+    public void setScale(Vector3 scale) {
+        // TODO(b/349391097): make this behavior consistent with ActivitySpaceImpl
+        throw new UnsupportedOperationException("Cannot set 'scale' on an AnchorEntity.");
+    }
+
+    // TODO: b/360168321 Use the OpenXrPosableHelper when retrieving the pose in activity space.
+    @Override
+    public Pose getPoseInActivitySpace() {
+        synchronized (this) {
+            if (activitySpace == null) {
+                throw new IllegalStateException(
+                        "Cannot get pose in Activity Space with a null Activity Space.");
+            }
+
+            if (state != State.ANCHORED) {
+                Log.w(
+                        TAG,
+                        "Cannot retrieve pose in underlying space. Ensure that the anchor is"
+                                + " anchored before calling this method. Returning identity pose.");
+                return new Pose();
+            }
+
+            // ActivitySpace and the anchor have unit scale and the anchor has no direct parent so
+            // we can
+            // just compose the two poses without scaling.
+            final Pose openXrToAnchor = this.getPoseInOpenXrReferenceSpace();
+            final Pose openXrToActivitySpace = activitySpace.getPoseInOpenXrReferenceSpace();
+            if (openXrToActivitySpace == null || openXrToAnchor == null) {
+                Log.e(
+                        TAG,
+                        "Cannot retrieve pose in underlying space despite anchor being anchored."
+                                + " Returning identity pose.");
+                return new Pose();
+            }
+
+            final Pose activitySpaceToOpenXr = openXrToActivitySpace.getInverse();
+            return activitySpaceToOpenXr.compose(openXrToAnchor);
+        }
+    }
+
+    // TODO: b/360168321 Use the OpenXrPosableHelper when retrieving the pose in world space.
+    @Override
+    public Pose getActivitySpacePose() {
+        if (activitySpaceRoot == null) {
+            throw new IllegalStateException(
+                    "Cannot get pose in World Space Pose with a null World Space Entity.");
+        }
+
+        // ActivitySpace and the anchor have unit scale and the anchor has no direct parent so we
+        // can
+        // just
+        // compose the two poses without scaling.
+        final Pose activitySpaceToAnchor = this.getPoseInActivitySpace();
+        final Pose worldSpaceToActivitySpace =
+                activitySpaceRoot.getPoseInActivitySpace().getInverse();
+        return worldSpaceToActivitySpace.compose(activitySpaceToAnchor);
+    }
+
+    @Override
+    public Vector3 getActivitySpaceScale() {
+        return getWorldSpaceScale().div(activitySpace.getWorldSpaceScale());
+    }
+
+    @Override
+    public void setParent(Entity parent) {
+        throw new UnsupportedOperationException("Cannot set 'parent' on an  AnchorEntity.");
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        Log.i(TAG, "Disposing " + this);
+
+        synchronized (this) {
+            // Return early if it is already in the error state.
+            if (state == State.ERROR) {
+                return;
+            }
+            updateState(State.ERROR);
+            if (anchor != null && !anchor.detach()) {
+                Log.e(TAG, "Error when detaching anchor.");
+            }
+            anchor = null;
+        }
+
+        // Set the parent of the CPM node to null; to hide the anchored content.The parent of the
+        // entity
+        // was always null so does not need to be reset.
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setAnchorId(node, null);
+            transaction.setParent(node, null).apply();
+        }
+        super.dispose();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorPlacementImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorPlacementImpl.java
new file mode 100644
index 0000000..65f75a5
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AnchorPlacementImpl.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Constructor for an AnchorPlacement.
+ *
+ * <p>Setting a [PlaneType] or [PlaneSemantic] anchor placement means that the [Entity] with a
+ * [MovableComponent] will be anchored to a plane of that [PlaneType] or [PlaneSemantic] if it is
+ * released while nearby after being moved. If no [PlaneType] or [PlaneSemantic] is set the [Entity]
+ * will not be anchored.
+ */
+class AnchorPlacementImpl implements AnchorPlacement {
+    protected Set<PlaneType> planeTypeFilter = new HashSet<>();
+    protected Set<PlaneSemantic> planeSemanticFilter = new HashSet<>();
+
+    AnchorPlacementImpl() {}
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AndroidXrEntity.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AndroidXrEntity.java
new file mode 100644
index 0000000..1bbb7d8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AndroidXrEntity.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.xr.extensions.Consumer;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.InputEvent;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent;
+import androidx.xr.scenecore.common.BaseEntity;
+
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of a RealityCore Entity that wraps an android XR extension Node.
+ *
+ * <p>This should not be created on its own but should be inherited by objects that need to wrap an
+ * Android extension node.
+ */
+@SuppressWarnings({"BanSynchronizedMethods", "BanConcurrentHashMap"})
+abstract class AndroidXrEntity extends BaseEntity implements Entity {
+
+    protected final Node node;
+    protected final XrExtensions extensions;
+    protected final ScheduledExecutorService executor;
+    // Visible for testing
+    final ConcurrentHashMap<InputEventListener, Executor> inputEventListenerMap =
+            new ConcurrentHashMap<>();
+    Optional<InputEventListener> pointerCaptureInputEventListener = Optional.empty();
+    Optional<Executor> pointerCaptureExecutor = Optional.empty();
+    final ConcurrentHashMap<Consumer<ReformEvent>, Executor> reformEventConsumerMap =
+            new ConcurrentHashMap<>();
+    private final EntityManager entityManager;
+    private ReformOptions reformOptions;
+
+    AndroidXrEntity(
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        this.node = node;
+        this.extensions = extensions;
+        this.entityManager = entityManager;
+        this.executor = executor;
+        entityManager.setEntityForNode(node, this);
+    }
+
+    @Override
+    public void setPose(Pose pose) {
+        // TODO: b/321268237 - Minimize the number of node transactions
+        super.setPose(pose);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction
+                    .setPosition(
+                            node,
+                            pose.getTranslation().getX(),
+                            pose.getTranslation().getY(),
+                            pose.getTranslation().getZ())
+                    .setOrientation(
+                            node,
+                            pose.getRotation().getX(),
+                            pose.getRotation().getY(),
+                            pose.getRotation().getZ(),
+                            pose.getRotation().getW())
+                    .apply();
+        }
+    }
+
+    @Override
+    public void setScale(Vector3 scale) {
+        super.setScale(scale);
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setScale(node, scale.getX(), scale.getY(), scale.getZ()).apply();
+        }
+    }
+
+    /** Returns the pose for this entity, relative to the activity space root. */
+    @Override
+    public Pose getPoseInActivitySpace() {
+        // TODO: b/355680575 - Revisit if we need to account for parent rotation when calculating
+        // the
+        // scale. This code might produce unexpected results when non-uniform scale is involved in
+        // the
+        // parent-child entity hierarchy.
+
+        // Any parentless "space" entities (such as the root and anchor entities) are expected to
+        // override this method non-recursively so that this error is never thrown.
+        if (!(getParent() instanceof AndroidXrEntity)) {
+            throw new IllegalStateException(
+                    "Cannot get pose in Activity Space with a non-AndroidXrEntity parent");
+        }
+        AndroidXrEntity xrParent = (AndroidXrEntity) getParent();
+        return xrParent.getPoseInActivitySpace()
+                .compose(
+                        new Pose(
+                                getPose().getTranslation().times(xrParent.getWorldSpaceScale()),
+                                getPose().getRotation()));
+    }
+
+    /**
+     * This method should be called when the ActivitySpace's underlying base space has been updated.
+     * For example, this method should be called when the task node moves in XrExtensions.
+     */
+    public void onActivitySpaceUpdated() {
+        // Defaults to a no-op.
+    }
+
+    // Returns the underlying extension Node for the Entity.
+    public Node getNode() {
+        return node;
+    }
+
+    @Override
+    public void setParent(Entity parent) {
+        if ((parent != null) && !(parent instanceof AndroidXrEntity)) {
+            Log.e(
+                    "RealityCoreRuntime",
+                    "Cannot set non-AndroidXrEntity as a parent of a AndroidXrEntity");
+            return;
+        }
+        super.setParent(parent);
+
+        AndroidXrEntity xrParent = (AndroidXrEntity) parent;
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            if (xrParent == null) {
+                transaction.setVisibility(node, false).setParent(node, null);
+            } else {
+                transaction.setParent(node, xrParent.getNode());
+            }
+            transaction.apply();
+        }
+    }
+
+    @Override
+    public void setSize(Dimensions dimensions) {
+        // TODO: b/326479171: Uncomment when extensions implement setSize.
+        // try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+        //   transaction.setSize(node, dimensions.width, dimensions.height,
+        // dimensions.depth).apply();
+        // }
+    }
+
+    @Override
+    public void setAlpha(float alpha) {
+        super.setAlpha(alpha);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setAlpha(node, alpha).apply();
+        }
+    }
+
+    @Override
+    public void setHidden(boolean hidden) {
+        super.setHidden(hidden);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            if (reformOptions != null) {
+                if (hidden) {
+                    // Since this entity is being hidden, disable reform and the highlights around
+                    // the node.
+                    transaction.disableReform(node);
+                } else {
+                    // Enables reform and the highlights around the node.
+                    transaction.enableReform(node, reformOptions);
+                }
+            }
+            transaction.setVisibility(node, !hidden).apply();
+        }
+    }
+
+    @Override
+    public void addInputEventListener(Executor executor, InputEventListener eventListener) {
+        maybeSetupInputListeners();
+        inputEventListenerMap.put(eventListener, executor == null ? this.executor : executor);
+    }
+
+    /**
+     * Request pointer capture for this Entity, using the given interfaces to propagate state and
+     * captured input.
+     *
+     * <p>Returns true if a new pointer capture session was requested. Returns false if there is
+     * already a previously existing pointer capture session as only one can be supported at a given
+     * time.
+     */
+    public boolean requestPointerCapture(
+            Executor executor,
+            InputEventListener eventListener,
+            PointerCaptureComponent.StateListener stateListener) {
+        if (pointerCaptureInputEventListener.isPresent()) {
+            return false;
+        }
+        getNode()
+                .requestPointerCapture(
+                        (pcState) -> {
+                            if (pcState == Node.POINTER_CAPTURE_STATE_PAUSED) {
+                                stateListener.onStateChanged(
+                                        PointerCaptureComponent.POINTER_CAPTURE_STATE_PAUSED);
+                            } else if (pcState == Node.POINTER_CAPTURE_STATE_ACTIVE) {
+                                stateListener.onStateChanged(
+                                        PointerCaptureComponent.POINTER_CAPTURE_STATE_ACTIVE);
+                            } else if (pcState == Node.POINTER_CAPTURE_STATE_STOPPED) {
+                                stateListener.onStateChanged(
+                                        PointerCaptureComponent.POINTER_CAPTURE_STATE_STOPPED);
+                            } else {
+                                Log.e("Runtime", "Invalid state received for pointer capture");
+                            }
+                        },
+                        executor);
+
+        addPointerCaptureInputListener(executor, eventListener);
+        return true;
+    }
+
+    private void addPointerCaptureInputListener(
+            Executor executor, InputEventListener eventListener) {
+        maybeSetupInputListeners();
+        pointerCaptureInputEventListener = Optional.of(eventListener);
+        pointerCaptureExecutor = Optional.ofNullable(executor);
+    }
+
+    private void maybeSetupInputListeners() {
+        if (inputEventListenerMap.isEmpty() && pointerCaptureInputEventListener.isEmpty()) {
+            node.listenForInput(
+                    (xrInputEvent) -> {
+                        if (xrInputEvent.getDispatchFlags()
+                                == InputEvent.DISPATCH_FLAG_CAPTURED_POINTER) {
+                            pointerCaptureInputEventListener.ifPresent(
+                                    (listener) ->
+                                            pointerCaptureExecutor
+                                                    .orElse(this.executor)
+                                                    .execute(
+                                                            () ->
+                                                                    listener.onInputEvent(
+                                                                            RuntimeUtils
+                                                                                    .getInputEvent(
+                                                                                            xrInputEvent,
+                                                                                            entityManager))));
+                        } else {
+                            inputEventListenerMap.forEach(
+                                    (inputEventListener, listenerExecutor) ->
+                                            listenerExecutor.execute(
+                                                    () ->
+                                                            inputEventListener.onInputEvent(
+                                                                    RuntimeUtils.getInputEvent(
+                                                                            xrInputEvent,
+                                                                            entityManager))));
+                        }
+                    },
+                    this.executor);
+        }
+    }
+
+    @Override
+    public void removeInputEventListener(InputEventListener consumer) {
+        inputEventListenerMap.remove(consumer);
+        maybeStopListeningForInput();
+    }
+
+    /** Stop any pointer capture requests on this Entity. */
+    public void stopPointerCapture() {
+        getNode().stopPointerCapture();
+        pointerCaptureInputEventListener = Optional.empty();
+        pointerCaptureExecutor = Optional.empty();
+        maybeStopListeningForInput();
+    }
+
+    private void maybeStopListeningForInput() {
+        if (inputEventListenerMap.isEmpty() && pointerCaptureInputEventListener.isEmpty()) {
+            node.stopListeningForInput();
+        }
+    }
+
+    @Override
+    public void dispose() {
+        inputEventListenerMap.clear();
+        node.stopListeningForInput();
+        reformEventConsumerMap.clear();
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.disableReform(node);
+        }
+
+        // SystemSpaceEntityImpls (Anchors, ActivitySpace, etc) should have null parents.
+        if (getParent() != null) {
+            setParent(null);
+        }
+        entityManager.removeEntityForNode(node);
+        super.dispose();
+    }
+
+    /**
+     * Gets the reform options for this entity.
+     *
+     * @return The reform options for this entity.
+     */
+    public ReformOptions getReformOptions() {
+        if (reformOptions == null) {
+            Consumer<ReformEvent> reformEventConsumer =
+                    reformEvent -> {
+                        if ((reformOptions.getEnabledReform() & ReformOptions.ALLOW_MOVE) != 0
+                                && (reformOptions.getFlags()
+                                                & ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT)
+                                        != 0) {
+                            // Update the cached pose of the entity.
+                            super.setPose(
+                                    new Pose(
+                                            new Vector3(
+                                                    reformEvent.getProposedPosition().x,
+                                                    reformEvent.getProposedPosition().y,
+                                                    reformEvent.getProposedPosition().z),
+                                            new Quaternion(
+                                                    reformEvent.getProposedOrientation().x,
+                                                    reformEvent.getProposedOrientation().y,
+                                                    reformEvent.getProposedOrientation().z,
+                                                    reformEvent.getProposedOrientation().w)));
+                            // Update the cached scale of the entity.
+                            super.setScaleInternal(
+                                    new Vector3(
+                                            reformEvent.getProposedScale().x,
+                                            reformEvent.getProposedScale().y,
+                                            reformEvent.getProposedScale().z));
+                        }
+                        reformEventConsumerMap.forEach(
+                                (eventConsumer, consumerExecutor) ->
+                                        consumerExecutor.execute(
+                                                () -> eventConsumer.accept(reformEvent)));
+                    };
+            reformOptions = extensions.createReformOptions(reformEventConsumer, executor);
+        }
+        return reformOptions;
+    }
+
+    /**
+     * Updates the reform options for this entity. Uses the same instance of [ReformOptions]
+     * provided by {@link #getReformOptions()}.
+     */
+    public synchronized void updateReformOptions() {
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            if (reformOptions.getEnabledReform() == 0) {
+                // Disables reform and the highlights around the node.
+                transaction.disableReform(node);
+            } else {
+                // Enables reform and the highlights around the node.
+                transaction.enableReform(node, reformOptions);
+            }
+            transaction.apply();
+        }
+    }
+
+    public void addReformEventConsumer(
+            Consumer<ReformEvent> reformEventConsumer, Executor executor) {
+        executor = (executor == null) ? this.executor : executor;
+        reformEventConsumerMap.put(reformEventConsumer, executor);
+    }
+
+    public void removeReformEventConsumer(Consumer<ReformEvent> reformEventConsumer) {
+        reformEventConsumerMap.remove(reformEventConsumer);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImpl.java
new file mode 100644
index 0000000..6e07e54
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImpl.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.media.AudioTrack;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.media.AudioTrackExtensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.AudioTrackExtensionsWrapper;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes;
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes;
+
+/** Implementation of the {@link AudioTrackExtensionsWrapper} */
+final class AudioTrackExtensionsWrapperImpl implements AudioTrackExtensionsWrapper {
+
+    private final AudioTrackExtensions extensions;
+
+    private final EntityManager entityManager;
+
+    AudioTrackExtensionsWrapperImpl(AudioTrackExtensions extensions, EntityManager entityManager) {
+        this.extensions = extensions;
+        this.entityManager = entityManager;
+    }
+
+    @Nullable
+    @Override
+    public PointSourceAttributes getPointSourceAttributes(@NonNull AudioTrack audioTrack) {
+        androidx.xr.extensions.media.PointSourceAttributes extAttributes =
+                extensions.getPointSourceAttributes(audioTrack);
+
+        if (extAttributes == null) {
+            return null;
+        }
+
+        Entity entity = entityManager.getEntityForNode(extAttributes.getNode());
+
+        if (entity == null) {
+            return null;
+        }
+
+        return new PointSourceAttributes(entity);
+    }
+
+    @Nullable
+    @Override
+    public SoundFieldAttributes getSoundFieldAttributes(@NonNull AudioTrack audioTrack) {
+        androidx.xr.extensions.media.SoundFieldAttributes extAttributes =
+                extensions.getSoundFieldAttributes(audioTrack);
+
+        if (extAttributes == null) {
+            return null;
+        }
+
+        return new SoundFieldAttributes(extAttributes.getAmbisonicsOrder());
+    }
+
+    @Override
+    public int getSpatialSourceType(@NonNull AudioTrack audioTrack) {
+        return extensions.getSpatialSourceType(audioTrack);
+    }
+
+    @Override
+    @NonNull
+    public AudioTrack.Builder setPointSourceAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull PointSourceAttributes attributes) {
+        androidx.xr.extensions.media.PointSourceAttributes extAttributes =
+                MediaUtils.convertPointSourceAttributesToExtensions(attributes);
+
+        return extensions.setPointSourceAttributes(builder, extAttributes);
+    }
+
+    @Override
+    @NonNull
+    public AudioTrack.Builder setSoundFieldAttributes(
+            @NonNull AudioTrack.Builder builder, @NonNull SoundFieldAttributes attributes) {
+        androidx.xr.extensions.media.SoundFieldAttributes extAttributes =
+                MediaUtils.convertSoundFieldAttributesToExtensions(attributes);
+
+        return extensions.setSoundFieldAttributes(builder, extAttributes);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/BasePanelEntity.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/BasePanelEntity.java
new file mode 100644
index 0000000..38da5e0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/BasePanelEntity.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.content.res.Resources;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/** BasePanelEntity provides implementations of capabilities common to PanelEntities. */
+@SuppressWarnings("deprecation") // TODO(b/373435470): Remove
+abstract class BasePanelEntity extends AndroidXrEntity implements PanelEntity {
+    protected PixelDimensions pixelDimensions;
+    private float cornerRadius;
+
+    BasePanelEntity(
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(node, extensions, entityManager, executor);
+    }
+
+    private float getDefaultPixelDensity() {
+        return extensions
+                .getConfig()
+                .defaultPixelsPerMeter(Resources.getSystem().getDisplayMetrics().density);
+    }
+
+    @Override
+    public Vector3 getPixelDensity() {
+        Vector3 scale = getWorldSpaceScale();
+        float defaultPixelDensity = getDefaultPixelDensity();
+        return new Vector3(
+                defaultPixelDensity / scale.getX(),
+                defaultPixelDensity / scale.getY(),
+                defaultPixelDensity / scale.getZ());
+    }
+
+    @Override
+    public Dimensions getSize() {
+        Vector3 pixelDensity = getPixelDensity();
+        return new Dimensions(
+                pixelDimensions.width / pixelDensity.getX(),
+                pixelDimensions.height / pixelDensity.getY(),
+                0);
+    }
+
+    @Override
+    public void setSize(Dimensions dimensions) {
+        // TODO(b/352630025): remove this method.
+        setPixelDimensions(new PixelDimensions((int) dimensions.width, (int) dimensions.height));
+    }
+
+    @Override
+    public PixelDimensions getPixelDimensions() {
+        return pixelDimensions;
+    }
+
+    @Override
+    public void setPixelDimensions(PixelDimensions dimensions) {
+        pixelDimensions = dimensions;
+    }
+
+    @Override
+    public void setCornerRadius(float value) {
+        if (value < 0.0f) {
+            throw new IllegalArgumentException("Corner radius can't be negative: " + value);
+        }
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setCornerRadius(node, value).apply();
+            cornerRadius = value;
+        }
+    }
+
+    @Override
+    public float getCornerRadius() {
+        return cornerRadius;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImpl.java
new file mode 100644
index 0000000..cabab63
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImpl.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose;
+import androidx.xr.scenecore.common.BaseActivityPose;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.impl.perception.ViewProjection;
+import androidx.xr.scenecore.impl.perception.ViewProjections;
+
+/**
+ * A ActivityPose representing a user's camera. This can be used to determine the location and field
+ * of view of the camera.
+ */
+final class CameraViewActivityPoseImpl extends BaseActivityPose implements CameraViewActivityPose {
+    private static final String TAG = "CameraViewActivityPose";
+    private final PerceptionLibrary perceptionLibrary;
+    @CameraType private final int cameraType;
+    private final OpenXrActivityPoseHelper openXrActivityPoseHelper;
+    // Default the pose to null. A null pose indicates that the camera is not ready yet.
+    private Pose lastOpenXrPose = null;
+
+    public CameraViewActivityPoseImpl(
+            @CameraType int cameraType,
+            ActivitySpaceImpl activitySpace,
+            AndroidXrEntity activitySpaceRoot,
+            PerceptionLibrary perceptionLibrary) {
+        this.cameraType = cameraType;
+        this.perceptionLibrary = perceptionLibrary;
+        this.openXrActivityPoseHelper =
+                new OpenXrActivityPoseHelper(activitySpace, activitySpaceRoot);
+    }
+
+    @Override
+    public Pose getPoseInActivitySpace() {
+        return openXrActivityPoseHelper.getPoseInActivitySpace(getPoseInOpenXrReferenceSpace());
+    }
+
+    @Override
+    public Pose getActivitySpacePose() {
+        return openXrActivityPoseHelper.getActivitySpacePose(getPoseInOpenXrReferenceSpace());
+    }
+
+    @Override
+    public Vector3 getActivitySpaceScale() {
+        // This WorldPose is assumed to always have a scale of 1.0f in the OpenXR reference space.
+        return openXrActivityPoseHelper.getActivitySpaceScale(new Vector3(1f, 1f, 1f));
+    }
+
+    @Nullable
+    private ViewProjection getViewProjection() {
+        final Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Cannot retrieve the camera pose with a null perception session.");
+            return null;
+        }
+        ViewProjections perceptionViews = session.getStereoViews();
+        if (perceptionViews == null) {
+            Log.e(TAG, "Error retrieving the camera.");
+            return null;
+        }
+        if (cameraType == CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE) {
+            return perceptionViews.getLeftEye();
+        } else if (cameraType == CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE) {
+            return perceptionViews.getRightEye();
+        } else {
+            Log.w(TAG, "Unsupported camera type: " + cameraType);
+            return null;
+        }
+    }
+
+    /** Gets the pose in the OpenXR reference space. Can be null if it is not yet ready. */
+    @Nullable
+    public Pose getPoseInOpenXrReferenceSpace() {
+        ViewProjection viewProjection = getViewProjection();
+        if (viewProjection != null) {
+            lastOpenXrPose = RuntimeUtils.fromPerceptionPose(viewProjection.getPose());
+        }
+        return lastOpenXrPose;
+    }
+
+    @Override
+    @CameraType
+    public int getCameraType() {
+        return cameraType;
+    }
+
+    @Override
+    public Fov getFov() {
+        ViewProjection viewProjection = getViewProjection();
+        if (viewProjection == null) {
+            return new Fov(0, 0, 0, 0);
+        }
+        return RuntimeUtils.fovFromPerceptionFov(viewProjection.getFov());
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/EntityManager.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/EntityManager.java
new file mode 100644
index 0000000..4bb5465
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/EntityManager.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static java.util.stream.Collectors.toCollection;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages the mapping between {@link Node} and {@link Entity} for a given {@link
+ * JxrPlatformAdapterAxr}.
+ */
+@SuppressWarnings("BanConcurrentHashMap")
+final class EntityManager {
+    private final Map<Node, Entity> nodeEntityMap = new ConcurrentHashMap<>();
+
+    /**
+     * Returns the {@link Entity} associated with the given {@link Node}.
+     *
+     * @param node the {@link Node} to get the associated {@link Entity} for.
+     * @return the {@link Entity} associated with the given {@link Node}, or null if no such {@link
+     *     Entity} exists.
+     */
+    @Nullable
+    Entity getEntityForNode(@NonNull Node node) {
+        return nodeEntityMap.get(node);
+    }
+
+    /**
+     * Sets the {@link Entity} associated with the given {@link Node}.
+     *
+     * @param node the {@link Node} to set the associated {@link Entity} for.
+     * @param entity the {@link Entity} to associate with the given {@link Node}.
+     */
+    void setEntityForNode(@NonNull Node node, @NonNull Entity entity) {
+        nodeEntityMap.put(node, entity);
+    }
+
+    /**
+     * Returns a list of all {@link Entity}s of type {@code T} (including subtypes of {@code T}).
+     *
+     * @param entityClass the type of {@link Entity} to return.
+     * @return a list of all {@link Entity}s of type {@code T} (including subtypes of {@code T}).
+     */
+    <T extends Entity> List<T> getEntitiesOfType(@NonNull Class<T> entityClass) {
+        return nodeEntityMap.values().stream()
+                .filter(entityClass::isInstance)
+                .map(entityClass::cast)
+                .collect(toCollection(ArrayList::new));
+    }
+
+    /** Returns a collection of all {@link Entity}s. */
+    Collection<Entity> getAllEntities() {
+        return nodeEntityMap.values();
+    }
+
+    /** Removes the given {@link Node} from the map. */
+    void removeEntityForNode(@NonNull Node node) {
+        nodeEntityMap.remove(node);
+    }
+
+    /** Clears the EntityManager. */
+    void clear() {
+        nodeEntityMap.clear();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ExrImageResourceImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ExrImageResourceImpl.java
new file mode 100644
index 0000000..c011afa
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ExrImageResourceImpl.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource;
+
+/**
+ * Implementation of a RealityCore ExrImageResource.
+ *
+ * <p>EXR Images are high dynamic range images that can be used as environmental skyboxes, and can
+ * be used for Image Based Lighting.
+ */
+@SuppressWarnings({"deprecation", "UnnecessarilyFullyQualified"}) // TODO(b/373435470): Remove
+final class ExrImageResourceImpl implements ExrImageResource {
+    // Note: right now the "environment" format accessible through the XRExtensions layer is .EXR
+    private final androidx.xr.extensions.asset.EnvironmentToken token;
+
+    public ExrImageResourceImpl(androidx.xr.extensions.asset.EnvironmentToken token) {
+        this.token = token;
+    }
+
+    public androidx.xr.extensions.asset.EnvironmentToken getToken() {
+        return token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImpl.java
new file mode 100644
index 0000000..622b2e7
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImpl.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of a RealityCore GltfEntity.
+ *
+ * <p>This is used to create an entity that contains a glTF object.
+ */
+// TODO: b/321782625 - Add tests when the Extensions can be faked.
+@SuppressWarnings("deprecation") // TODO(b/373435470): Remove
+final class GltfEntityImpl extends AndroidXrEntity implements GltfEntity {
+    public GltfEntityImpl(
+            GltfModelResourceImpl gltfModelResource,
+            Entity parentEntity,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(extensions.createNode(), extensions, entityManager, executor);
+        setParent(parentEntity);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setGltfModel(node, gltfModelResource.getExtensionModelToken()).apply();
+        }
+    }
+
+    @Override
+    public void startAnimation(boolean looping, @Nullable String animationName) {
+        // Implement this for the non-Split Engine path or ignore until the Split
+        // Engine path becomes the default.
+        Log.w("GltfEntityImpl: ", "GLTF Animation is only supported when using SplitEngine.");
+    }
+
+    @Override
+    public void stopAnimation() {
+        // Implement this for the non-Split Engine path or ignore until the Split
+        // Engine path becomes the default.
+        Log.w("GltfEntityImpl: ", "GLTF Animation is only supported when using SplitEngine.");
+    }
+
+    @Override
+    @AnimationState
+    public int getAnimationState() {
+        // Implement this for the non-Split Engine path or ignore until the Split
+        // Engine path becomes the default.
+        Log.w("GltfEntityImpl: ", "GLTF Animation is only supported when using SplitEngine.");
+        return AnimationState.STOPPED;
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        Log.i("GltfEntityImpl", "Disposing " + this);
+        super.dispose();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImplSplitEngine.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImplSplitEngine.java
new file mode 100644
index 0000000..242c3c7
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfEntityImplSplitEngine.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.androidxr.splitengine.SubspaceNode;
+import com.google.ar.imp.apibindings.ImpressApi;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of a RealityCore GltfEntitySplitEngine.
+ *
+ * <p>This is used to create an entity that contains a glTF object using the Split Engine route.
+ */
+// TODO: b/375520647 - Add unit tests for this class.
+class GltfEntityImplSplitEngine extends AndroidXrEntity implements GltfEntity {
+    private final ImpressApi impressApi;
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager;
+    private final SubspaceNode subspace;
+    private final int modelImpressNode;
+    private final int subspaceImpressNode;
+    @AnimationState private int animationState = AnimationState.STOPPED;
+
+    public GltfEntityImplSplitEngine(
+            GltfModelResourceImplSplitEngine gltfModelResource,
+            Entity parentEntity,
+            ImpressApi impressApi,
+            SplitEngineSubspaceManager splitEngineSubspaceManager,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(extensions.createNode(), extensions, entityManager, executor);
+        this.impressApi = impressApi;
+        this.splitEngineSubspaceManager = splitEngineSubspaceManager;
+        setParent(parentEntity);
+
+        // TODO(b/377907379): - Punt this logic to the UI thread, so that applications can create
+        // Gltf entities from any thread.
+
+        // System will only render Impress nodes that are parented by this subspace node.
+        this.subspaceImpressNode = impressApi.createImpressNode();
+        String subspaceName = "gltf_entity_subspace_" + subspaceImpressNode;
+
+        this.subspace =
+                splitEngineSubspaceManager.createSubspace(subspaceName, subspaceImpressNode);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            // Make the Entity node a parent of the subspace node.
+            transaction.setParent(subspace.subspaceNode, this.node).apply();
+        }
+        this.modelImpressNode =
+                impressApi.instanceGltfModel(gltfModelResource.getExtensionModelToken());
+        impressApi.setImpressNodeParent(modelImpressNode, subspaceImpressNode);
+        // The Impress node hierarchy is: Subspace Impress node --- parent of ---> model Impress
+        // node.
+        // The CPM node hierarchy is: Entity CPM node --- parent of ---> Subspace CPM node.
+    }
+
+    @Override
+    public void startAnimation(boolean looping, @Nullable String animationName) {
+        // TODO: b/362826747 - Add a listener interface so that the application can be
+        // notified that the animation has stopped, been cancelled (by starting another animation)
+        // and / or shown an error state if something went wrong.
+
+        // TODO(b/377907379): - Punt this logic to the UI thread.
+
+        // Note that at the moment this future will be garbage collected, since we don't return it
+        // from
+        // this method.
+        ListenableFuture<Void> future =
+                impressApi.animateGltfModel(modelImpressNode, animationName, looping);
+        animationState = AnimationState.PLAYING;
+
+        // At the moment, we don't do anything interesting on failure except for logging. If we
+        // didn't
+        // care about logging the failure, we could just not register a listener at all if the
+        // animation
+        // is looping, since it will never terminate normally.
+        future.addListener(
+                () -> {
+                    try {
+                        future.get();
+                        // The animation played to completion and has stopped
+                        animationState = AnimationState.STOPPED;
+                    } catch (Exception e) {
+                        if (e instanceof InterruptedException) {
+                            // If this happened, then it's likely Impress is shutting down and we
+                            // need to
+                            // shut down as well.
+                            Thread.currentThread().interrupt();
+                        } else {
+                            // Some other error happened.  Log it and stop the animation.
+                            Log.e("GltfEntityImpl", "Could not start animation: " + e);
+                            animationState = AnimationState.STOPPED;
+                        }
+                    }
+                },
+                this.executor);
+    }
+
+    @Override
+    public void stopAnimation() {
+        // TODO(b/377907379): - Punt this logic to the UI thread.
+        impressApi.stopGltfModelAnimation(modelImpressNode);
+        animationState = AnimationState.STOPPED;
+    }
+
+    @Override
+    @AnimationState
+    public int getAnimationState() {
+        return animationState;
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        // TODO(b/377907379): - Punt this logic to the UI thread.
+        splitEngineSubspaceManager.deleteSubspace(subspace.subspaceId);
+        impressApi.destroyImpressNode(modelImpressNode);
+        impressApi.destroyImpressNode(subspaceImpressNode);
+        super.dispose();
+    }
+
+    public void setColliderEnabled(boolean enableCollider) {
+        // TODO(b/377907379): - Punt this logic to the UI thread
+        impressApi.setGltfModelColliderEnabled(modelImpressNode, enableCollider);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImpl.java
new file mode 100644
index 0000000..2dca9f2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImpl.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource;
+
+/**
+ * Implementation of a RealityCore GltfModelResource.
+ *
+ * <p>This is used to create to load a glTF that can later be used when creating a GltfEntity.
+ */
+@SuppressWarnings({"deprecation", "UnnecessarilyFullyQualified"}) // TODO(b/373435470): Remove
+final class GltfModelResourceImpl implements GltfModelResource {
+    private final androidx.xr.extensions.asset.GltfModelToken token;
+
+    public GltfModelResourceImpl(androidx.xr.extensions.asset.GltfModelToken token) {
+        this.token = token;
+    }
+
+    public androidx.xr.extensions.asset.GltfModelToken getExtensionModelToken() {
+        return token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImplSplitEngine.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImplSplitEngine.java
new file mode 100644
index 0000000..13681ef
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/GltfModelResourceImplSplitEngine.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource;
+
+/**
+ * Implementation of a RealityCore GltfModelResource for the Split Engine.
+ *
+ * <p>This is used to create to load a glTF that can later be used when creating a
+ * GltfEntitySplitEngine.
+ */
+// TODO: b/362368652 - Add an interface which returns an integer animation IDX given a string
+//                     animation name for a loaded GLTF.
+final class GltfModelResourceImplSplitEngine implements GltfModelResource {
+    private final long token;
+
+    public GltfModelResourceImplSplitEngine(long token) {
+        this.token = token;
+    }
+
+    public long getExtensionModelToken() {
+        return token;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/HeadActivityPoseImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/HeadActivityPoseImpl.java
new file mode 100644
index 0000000..d1b9b59
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/HeadActivityPoseImpl.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.annotation.Nullable;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.HeadActivityPose;
+import androidx.xr.scenecore.common.BaseActivityPose;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+
+/**
+ * An ActivityPose representing the head of the user. This can be used to determine the location of
+ * the user's head.
+ */
+class HeadActivityPoseImpl extends BaseActivityPose implements HeadActivityPose {
+    private final PerceptionLibrary perceptionLibrary;
+    private final OpenXrActivityPoseHelper openXrActivityPoseHelper;
+    // Default the pose to null. A null pose indicates that the head is not ready yet.
+    private Pose lastOpenXrPose = null;
+
+    public HeadActivityPoseImpl(
+            ActivitySpaceImpl activitySpace,
+            AndroidXrEntity activitySpaceRoot,
+            PerceptionLibrary perceptionLibrary) {
+        this.perceptionLibrary = perceptionLibrary;
+        this.openXrActivityPoseHelper =
+                new OpenXrActivityPoseHelper(activitySpace, activitySpaceRoot);
+    }
+
+    @Override
+    public Pose getPoseInActivitySpace() {
+        return openXrActivityPoseHelper.getPoseInActivitySpace(getPoseInOpenXrReferenceSpace());
+    }
+
+    @Override
+    public Pose getActivitySpacePose() {
+        return openXrActivityPoseHelper.getActivitySpacePose(getPoseInOpenXrReferenceSpace());
+    }
+
+    @Override
+    public Vector3 getActivitySpaceScale() {
+        // This WorldPose is assumed to always have a scale of 1.0f in the OpenXR reference space.
+        return openXrActivityPoseHelper.getActivitySpaceScale(new Vector3(1f, 1f, 1f));
+    }
+
+    /** Gets the pose in the OpenXR reference space. Can be null if it is not yet ready. */
+    @Nullable
+    public Pose getPoseInOpenXrReferenceSpace() {
+        final Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            return lastOpenXrPose;
+        }
+        androidx.xr.scenecore.impl.perception.Pose perceptionHeadPose = session.getHeadPose();
+        if (perceptionHeadPose != null) {
+            lastOpenXrPose = RuntimeUtils.fromPerceptionPose(perceptionHeadPose);
+        }
+        return lastOpenXrPose;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/InteractableComponentImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/InteractableComponentImpl.java
new file mode 100644
index 0000000..f2d5118
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/InteractableComponentImpl.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.InteractableComponent;
+
+import java.util.concurrent.Executor;
+
+/** Implementation of [JxrPlatformAdapter.InteractableComponent]. */
+class InteractableComponentImpl implements InteractableComponent {
+    final InputEventListener consumer;
+    final Executor executor;
+    Entity entity;
+
+    InteractableComponentImpl(Executor executor, InputEventListener consumer) {
+        this.consumer = consumer;
+        this.executor = executor;
+    }
+
+    @Override
+    public boolean onAttach(Entity entity) {
+        if (this.entity != null) {
+            Log.e("Runtime", "Already attached to entity " + this.entity);
+            return false;
+        }
+        this.entity = entity;
+        if (entity instanceof GltfEntityImplSplitEngine) {
+            ((GltfEntityImplSplitEngine) entity).setColliderEnabled(true);
+        }
+        // InputEvent type translation happens here.
+        entity.addInputEventListener(executor, consumer);
+        return true;
+    }
+
+    @Override
+    public void onDetach(Entity entity) {
+        if (entity instanceof GltfEntityImplSplitEngine) {
+            ((GltfEntityImplSplitEngine) entity).setColliderEnabled(false);
+        }
+        entity.removeInputEventListener(consumer);
+        this.entity = null;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxr.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxr.java
new file mode 100644
index 0000000..47da4bd
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxr.java
@@ -0,0 +1,1174 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.SurfaceControlViewHost;
+import android.view.SurfaceControlViewHost.SurfacePackage;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.concurrent.futures.ResolvableFuture;
+import androidx.xr.arcore.Anchor;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.XrExtensionsProvider;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.space.ActivityPanel;
+import androidx.xr.extensions.space.ActivityPanelLaunchParameters;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.impl.perception.ViewProjections;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.apibindings.ImpressApi;
+import com.google.ar.imp.apibindings.ImpressApiImpl;
+import com.google.ar.imp.view.splitengine.ImpSplitEngine;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/** Implementation of JxrPlatformAdapter for AndroidXR. */
+// TODO: b/322550407 - Use the Android Fluent Logger
+// TODO(b/373435470): Remove "deprecation" and "UnnecessarilyFullyQualified"
+@SuppressWarnings({
+    "deprecation",
+    "UnnecessarilyFullyQualified",
+    "BanSynchronizedMethods",
+    "BanConcurrentHashMap",
+})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class JxrPlatformAdapterAxr implements JxrPlatformAdapter {
+    private static final String TAG = "JxrPlatformAdapterAxr";
+    private static final String SPLIT_ENGINE_LIBRARY_NAME = "impress_api_jni";
+
+    private final ActivitySpaceImpl activitySpace;
+    private final HeadActivityPoseImpl headActivityPose;
+    private final PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose;
+    private final List<CameraViewActivityPoseImpl> cameraActivityPoses = new ArrayList<>();
+    private final ScheduledExecutorService executor;
+    private final XrExtensions extensions;
+
+    private final SoundPoolExtensionsWrapper soundPoolExtensionsWrapper;
+    private final AudioTrackExtensionsWrapper audioTrackExtensionsWrapper;
+    private final MediaPlayerExtensionsWrapper mediaPlayerExtensionsWrapper;
+
+    private final PerceptionLibrary perceptionLibrary;
+    private final SpatialEnvironmentImpl environment;
+    private final boolean useSplitEngine;
+    private final Node taskWindowLeashNode;
+    private final int openXrReferenceSpaceType;
+    private final EntityManager entityManager;
+    private final PanelEntity mainPanelEntity;
+    private final ImpressApi impressApi;
+    private final Map<Consumer<SpatialCapabilities>, Executor> spatialCapabilitiesChangedListeners =
+            new ConcurrentHashMap<>();
+
+    @Nullable private Activity activity;
+    private SplitEngineSubspaceManager splitEngineSubspaceManager;
+    private ImpSplitEngineRenderer splitEngineRenderer;
+    private boolean frameLoopStarted;
+
+    // TODO b/373481538: remove lazy initialization once XR Extensions bug is fixed. This will allow
+    // us to remove the lazySpatialStateProvider instance and pass the spatialState directly.
+    private final AtomicReference<SpatialState> spatialState = new AtomicReference<>(null);
+
+    // Returns the currently-known spatial state, or fetches it from the extensions if it has never
+    // been set. The spatial state is kept updated in the SpatialStateCallback.
+    private final Supplier<SpatialState> lazySpatialStateProvider;
+
+    private JxrPlatformAdapterAxr(
+            Activity activity,
+            ScheduledExecutorService executor,
+            XrExtensions extensions,
+            @Nullable ImpressApi impressApi,
+            EntityManager entityManager,
+            PerceptionLibrary perceptionLibrary,
+            @Nullable SplitEngineSubspaceManager subspaceManager,
+            @Nullable ImpSplitEngineRenderer renderer,
+            Node rootSceneNode,
+            Node taskWindowLeashNode,
+            boolean useSplitEngine) {
+        this.activity = activity;
+        this.executor = executor;
+        this.extensions = extensions;
+
+        this.lazySpatialStateProvider =
+                () ->
+                        spatialState.updateAndGet(
+                                oldSpatialState -> {
+                                    if (oldSpatialState == null) {
+                                        oldSpatialState = this.extensions.getSpatialState(activity);
+                                    }
+                                    return oldSpatialState;
+                                });
+        setSpatialStateCallback();
+
+        soundPoolExtensionsWrapper =
+                new SoundPoolExtensionsWrapperImpl(
+                        extensions.getXrSpatialAudioExtensions().getSoundPoolExtensions());
+        audioTrackExtensionsWrapper =
+                new AudioTrackExtensionsWrapperImpl(
+                        extensions.getXrSpatialAudioExtensions().getAudioTrackExtensions(),
+                        entityManager);
+        mediaPlayerExtensionsWrapper =
+                new MediaPlayerExtensionsWrapperImpl(
+                        extensions.getXrSpatialAudioExtensions().getMediaPlayerExtensions());
+        this.entityManager = entityManager;
+        this.perceptionLibrary = perceptionLibrary;
+        this.taskWindowLeashNode = taskWindowLeashNode;
+        this.environment =
+                new SpatialEnvironmentImpl(
+                        activity,
+                        extensions,
+                        rootSceneNode,
+                        lazySpatialStateProvider,
+                        useSplitEngine);
+        this.activitySpace =
+                new ActivitySpaceImpl(
+                        rootSceneNode,
+                        extensions,
+                        entityManager,
+                        lazySpatialStateProvider,
+                        executor);
+        this.headActivityPose =
+                new HeadActivityPoseImpl(
+                        activitySpace,
+                        (AndroidXrEntity) getActivitySpaceRootImpl(),
+                        perceptionLibrary);
+        this.perceptionSpaceActivityPose =
+                new PerceptionSpaceActivityPoseImpl(
+                        activitySpace, (AndroidXrEntity) getActivitySpaceRootImpl());
+        this.cameraActivityPoses.add(
+                new CameraViewActivityPoseImpl(
+                        CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE,
+                        activitySpace,
+                        (AndroidXrEntity) getActivitySpaceRootImpl(),
+                        perceptionLibrary));
+        this.cameraActivityPoses.add(
+                new CameraViewActivityPoseImpl(
+                        CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE,
+                        activitySpace,
+                        (AndroidXrEntity) getActivitySpaceRootImpl(),
+                        perceptionLibrary));
+        this.useSplitEngine = useSplitEngine;
+        this.openXrReferenceSpaceType = extensions.getOpenXrWorldSpaceType();
+
+        this.mainPanelEntity =
+                new MainPanelEntityImpl(
+                        activity, taskWindowLeashNode, extensions, entityManager, executor);
+        this.mainPanelEntity.setParent(activitySpace);
+
+        // TODO:b/377918731 - Move this logic into factories and inject SE into the constructor
+        if (impressApi == null) {
+            // TODO: b/370116937) - Check against useSplitEngine as well and don't load this if
+            //                      SplitEngine is disabled.
+            this.impressApi = new ImpressApiImpl();
+        } else {
+            this.impressApi = impressApi;
+        }
+
+        if (useSplitEngine && subspaceManager == null && renderer == null) {
+            ImpSplitEngine.SplitEngineSetupParams impApiSetupParams =
+                    new ImpSplitEngine.SplitEngineSetupParams();
+            impApiSetupParams.jniLibraryName = SPLIT_ENGINE_LIBRARY_NAME;
+            this.splitEngineRenderer = ImpSplitEngineRenderer.create(activity, impApiSetupParams);
+            startRenderer();
+            this.splitEngineSubspaceManager =
+                    new SplitEngineSubspaceManager(
+                            this.splitEngineRenderer,
+                            rootSceneNode,
+                            taskWindowLeashNode,
+                            SPLIT_ENGINE_LIBRARY_NAME);
+            this.impressApi.setup(splitEngineRenderer.getView());
+            environment.onSplitEngineReady(this.splitEngineSubspaceManager, this.impressApi);
+        }
+    }
+
+    @NonNull
+    public static JxrPlatformAdapterAxr create(
+            @NonNull Activity activity, @NonNull ScheduledExecutorService executor) {
+        return create(
+                activity,
+                executor,
+                XrExtensionsProvider.getXrExtensions(),
+                null,
+                new EntityManager(),
+                new PerceptionLibrary(),
+                null,
+                null,
+                /* useSplitEngine= */ true);
+    }
+
+    @NonNull
+    public static JxrPlatformAdapterAxr create(
+            @NonNull Activity activity,
+            @NonNull ScheduledExecutorService executor,
+            boolean useSplitEngine) {
+        return create(
+                activity,
+                executor,
+                XrExtensionsProvider.getXrExtensions(),
+                null,
+                new EntityManager(),
+                new PerceptionLibrary(),
+                null,
+                null,
+                useSplitEngine);
+    }
+
+    @NonNull
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static JxrPlatformAdapterAxr create(
+            @NonNull Activity activity,
+            @NonNull ScheduledExecutorService executor,
+            @NonNull Node rootSceneNode,
+            @NonNull Node taskWindowLeashNode) {
+        return create(
+                activity,
+                executor,
+                XrExtensionsProvider.getXrExtensions(),
+                null,
+                new EntityManager(),
+                new PerceptionLibrary(),
+                null,
+                null,
+                rootSceneNode,
+                taskWindowLeashNode,
+                /* useSplitEngine= */ false);
+    }
+
+    @NonNull
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static JxrPlatformAdapterAxr create(
+            @NonNull Activity activity,
+            @NonNull ScheduledExecutorService executor,
+            @NonNull XrExtensions extensions,
+            @Nullable ImpressApi impressApi,
+            @NonNull PerceptionLibrary perceptionLibrary,
+            @Nullable SplitEngineSubspaceManager splitEngineSubspaceManager,
+            @Nullable ImpSplitEngineRenderer splitEngineRenderer) {
+        return create(
+                activity,
+                executor,
+                extensions,
+                impressApi,
+                new EntityManager(),
+                perceptionLibrary,
+                splitEngineSubspaceManager,
+                splitEngineRenderer,
+                /* useSplitEngine= */ false);
+    }
+
+    static JxrPlatformAdapterAxr create(
+            Activity activity,
+            ScheduledExecutorService executor,
+            XrExtensions extensions,
+            ImpressApi impressApi,
+            EntityManager entityManager,
+            PerceptionLibrary perceptionLibrary,
+            SplitEngineSubspaceManager splitEngineSubspaceManager,
+            ImpSplitEngineRenderer splitEngineRenderer,
+            boolean useSplitEngine) {
+        Node rootSceneNode = extensions.createNode();
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setName(rootSceneNode, "RootSceneNode").apply();
+        }
+        Log.i(TAG, "Impl Node for task $activity.taskId is root scene node: " + rootSceneNode);
+        Node taskWindowLeashNode = extensions.createNode();
+        // TODO: b/376934871 - Check async results.
+        extensions.attachSpatialScene(
+                activity, rootSceneNode, taskWindowLeashNode, (result) -> {}, Runnable::run);
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction
+                    .setParent(taskWindowLeashNode, rootSceneNode)
+                    .setName(taskWindowLeashNode, "TaskWindowLeashNode")
+                    .apply();
+        }
+        return create(
+                activity,
+                executor,
+                extensions,
+                impressApi,
+                entityManager,
+                perceptionLibrary,
+                splitEngineSubspaceManager,
+                splitEngineRenderer,
+                rootSceneNode,
+                taskWindowLeashNode,
+                useSplitEngine);
+    }
+
+    static JxrPlatformAdapterAxr create(
+            @NonNull Activity activity,
+            @NonNull ScheduledExecutorService executor,
+            @NonNull XrExtensions extensions,
+            @Nullable ImpressApi impressApi,
+            @NonNull EntityManager entityManager,
+            @NonNull PerceptionLibrary perceptionLibrary,
+            @Nullable SplitEngineSubspaceManager splitEngineSubspaceManager,
+            @Nullable ImpSplitEngineRenderer splitEngineRenderer,
+            @NonNull Node rootSceneNode,
+            @NonNull Node taskWindowLeashNode,
+            boolean useSplitEngine) {
+        JxrPlatformAdapterAxr runtime =
+                new JxrPlatformAdapterAxr(
+                        activity,
+                        executor,
+                        extensions,
+                        impressApi,
+                        entityManager,
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        rootSceneNode,
+                        taskWindowLeashNode,
+                        useSplitEngine);
+
+        Log.i(TAG, "Initing perception library soon");
+        runtime.initPerceptionLibrary();
+        return runtime;
+    }
+
+    private static GltfModelResourceImpl getModelResourceFromToken(
+            androidx.xr.extensions.asset.GltfModelToken token) {
+        return new GltfModelResourceImpl(token);
+    }
+
+    private static GltfModelResourceImplSplitEngine getModelResourceFromTokenSplitEngine(
+            long token) {
+        return new GltfModelResourceImplSplitEngine(token);
+    }
+
+    private static ExrImageResourceImpl getExrImageResourceFromToken(
+            androidx.xr.extensions.asset.EnvironmentToken token) {
+        return new ExrImageResourceImpl(token);
+    }
+
+    // Note that this is called on the Activity's UI thread so we should be careful to not  block
+    // it.
+    // It is synchronized because we assume this.spatialState cannot be updated elsewhere during the
+    // execution of this method.
+    @VisibleForTesting
+    synchronized void onSpatialStateChanged(
+            @NonNull androidx.xr.extensions.space.SpatialState newSpatialState) {
+        SpatialState previousSpatialState = this.spatialState.getAndSet(newSpatialState);
+
+        boolean spatialCapabilitiesChanged =
+                previousSpatialState == null
+                        || !newSpatialState
+                                .getSpatialCapabilities()
+                                .equals(previousSpatialState.getSpatialCapabilities());
+
+        boolean hasBoundsChanged =
+                previousSpatialState == null
+                        || !newSpatialState.getBounds().equals(previousSpatialState.getBounds());
+
+        EnumSet<SpatialEnvironmentImpl.ChangedSpatialStates> changedSpatialStates =
+                environment.setSpatialState(newSpatialState);
+        boolean environmentVisibilityChanged =
+                changedSpatialStates.contains(
+                        SpatialEnvironmentImpl.ChangedSpatialStates.ENVIRONMENT_CHANGED);
+        boolean passthroughVisibilityChanged =
+                changedSpatialStates.contains(
+                        SpatialEnvironmentImpl.ChangedSpatialStates.PASSTHROUGH_CHANGED);
+
+        // Fire the state change events only after all the states have been updated.
+        if (environmentVisibilityChanged) {
+            environment.fireOnSpatialEnvironmentChangedEvent(
+                    environment.isSpatialEnvironmentPreferenceActive());
+        }
+        if (passthroughVisibilityChanged) {
+            environment.firePassthroughOpacityChangedEvent(
+                    environment.getCurrentPassthroughOpacity());
+        }
+
+        if (spatialCapabilitiesChanged) {
+            SpatialCapabilities spatialCapabilities =
+                    RuntimeUtils.convertSpatialCapabilities(
+                            newSpatialState.getSpatialCapabilities());
+
+            spatialCapabilitiesChangedListeners.forEach(
+                    (listener, executor) ->
+                            executor.execute(() -> listener.accept(spatialCapabilities)));
+        }
+
+        if (hasBoundsChanged) {
+            activitySpace.onBoundsChanged(newSpatialState.getBounds());
+        }
+    }
+
+    private void setSpatialStateCallback() {
+        Handler mainHandler = new Handler(Looper.getMainLooper());
+        extensions.registerSpatialStateCallback(
+                activity, this::onSpatialStateChanged, mainHandler::post);
+    }
+
+    private synchronized void initPerceptionLibrary() {
+        if (perceptionLibrary.getSession() != null) {
+            Log.w(TAG, "Cannot init perception session, already initialized.");
+            return;
+        }
+        ListenableFuture<Session> sessionFuture =
+                perceptionLibrary.initSession(activity, openXrReferenceSpaceType, executor);
+        Objects.requireNonNull(sessionFuture)
+                .addListener(
+                        () -> {
+                            try {
+                                sessionFuture.get();
+                            } catch (Exception e) {
+                                if (e instanceof InterruptedException) {
+                                    Thread.currentThread().interrupt();
+                                }
+                                Log.e(
+                                        TAG,
+                                        "Failed to init perception session with error: "
+                                                + e.getMessage());
+                            }
+                        },
+                        executor);
+    }
+
+    @Override
+    @NonNull
+    public SpatialCapabilities getSpatialCapabilities() {
+        return RuntimeUtils.convertSpatialCapabilities(
+                lazySpatialStateProvider.get().getSpatialCapabilities());
+    }
+
+    @Override
+    public void addSpatialCapabilitiesChangedListener(
+            @NonNull Executor executor, @NonNull Consumer<SpatialCapabilities> listener) {
+        spatialCapabilitiesChangedListeners.put(listener, executor);
+    }
+
+    @Override
+    public void removeSpatialCapabilitiesChangedListener(
+            @NonNull Consumer<SpatialCapabilities> listener) {
+        spatialCapabilitiesChangedListeners.remove(listener);
+    }
+
+    @Override
+    @NonNull
+    public LoggingEntity createLoggingEntity(@NonNull Pose pose) {
+        LoggingEntityImpl entity = new LoggingEntityImpl();
+        entity.setPose(pose);
+        return entity;
+    }
+
+    @Override
+    @NonNull
+    public SpatialEnvironment getSpatialEnvironment() {
+        return environment;
+    }
+
+    @Override
+    @NonNull
+    public ActivitySpace getActivitySpace() {
+        return activitySpace;
+    }
+
+    @Override
+    @Nullable
+    public HeadActivityPose getHeadActivityPose() {
+        // If it is unable to retrieve a pose the head in not yet loaded in openXR so return null.
+        if (headActivityPose.getPoseInOpenXrReferenceSpace() == null) {
+            return null;
+        }
+        return headActivityPose;
+    }
+
+    @Override
+    @Nullable
+    public CameraViewActivityPose getCameraViewActivityPose(
+            @CameraViewActivityPose.CameraType int cameraType) {
+        CameraViewActivityPoseImpl cameraViewActivityPose = null;
+        if (cameraType == CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE) {
+            cameraViewActivityPose = cameraActivityPoses.get(0);
+        } else if (cameraType == CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE) {
+            cameraViewActivityPose = cameraActivityPoses.get(1);
+        }
+        // If it is unable to retrieve a pose the camera in not yet loaded in openXR so return null.
+        if (cameraViewActivityPose == null
+                || cameraViewActivityPose.getPoseInOpenXrReferenceSpace() == null) {
+            return null;
+        }
+        return cameraViewActivityPose;
+    }
+
+    @Override
+    @NonNull
+    public PerceptionSpaceActivityPose getPerceptionSpaceActivityPose() {
+        return perceptionSpaceActivityPose;
+    }
+
+    // TODO(b/349180723): Refactor to a streaming based approach.
+    @Nullable
+    public Pose getHeadPoseInOpenXrUnboundedSpace() {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Perception session is uninitialized, returning null head pose.");
+            return null;
+        }
+        return RuntimeUtils.fromPerceptionPose(Objects.requireNonNull(session.getHeadPose()));
+    }
+
+    @Nullable
+    public ViewProjections getStereoViewsInOpenXrUnboundedSpace() {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Perception session is uninitialized, returning null head pose.");
+            return null;
+        }
+        return session.getStereoViews();
+    }
+
+    @Override
+    @NonNull
+    public Entity getActivitySpaceRootImpl() {
+        // Trivially returns the activity space for now, but it could be updated to return any other
+        // singleton space entity. That space entity will define the world space origin of the SDK
+        // and
+        // will be the default parent for new content entities.
+
+        return activitySpace;
+    }
+
+    @Override
+    public void requestFullSpaceMode() {
+        // TODO: b/376934871 - Check async results.
+        extensions.requestFullSpaceMode(
+                activity, /* requestEnter= */ true, (result) -> {}, Runnable::run);
+    }
+
+    @Override
+    public void requestHomeSpaceMode() {
+        // TODO: b/376934871 - Check async results.
+        extensions.requestFullSpaceMode(
+                activity, /* requestEnter= */ false, (result) -> {}, Runnable::run);
+    }
+
+    // TODO: b/374345896 - Delete this method once we've finalized the SplitEngine migration.
+    @SuppressWarnings({
+        "AndroidJdkLibsChecker",
+        "RestrictTo",
+        "FutureReturnValueIgnored",
+        "AsyncSuffixFuture"
+    })
+    @Override
+    @Nullable
+    public ListenableFuture<GltfModelResource> loadGltfByAssetName(@NonNull String assetName) {
+        ResolvableFuture<GltfModelResource> gltfModelResourceFuture = ResolvableFuture.create();
+        InputStream asset;
+        try {
+            asset = activity.getAssets().open(assetName);
+        } catch (Exception e) {
+            Log.w(TAG, "Could not open asset with error: " + e.getMessage());
+            return null;
+        }
+
+        CompletableFuture<androidx.xr.extensions.asset.GltfModelToken> tokenFuture;
+        try {
+            tokenFuture = extensions.loadGltfModel(asset, asset.available(), 0, assetName);
+            // Unfortunately, there is no way to avoid "leaking" this future, since we want to
+            // return a
+            // ListenableFuture. This should be a short lived problem since clients should be using
+            // loadGltfByAssetNameSplitEngine() if they have SplitEngine enabled.
+            tokenFuture.thenApply(
+                    token -> gltfModelResourceFuture.set(getModelResourceFromToken(token)));
+        } catch (Exception e) {
+            Log.w(TAG, "Could not load glTF model with error: " + e.getMessage());
+            return null;
+        }
+
+        return gltfModelResourceFuture;
+    }
+
+    // ResolvableFuture is marked as RestrictTo(LIBRARY_GROUP_PREFIX), which is intended for classes
+    // within AndroidX. We're in the process of migrating to AndroidX. Without suppressing this
+    // warning, however, we get a build error - go/bugpattern/RestrictTo.
+    @SuppressWarnings({"RestrictTo", "AsyncSuffixFuture"})
+    @Override
+    @Nullable
+    public ListenableFuture<GltfModelResource> loadGltfByAssetNameSplitEngine(
+            @NonNull String name) {
+        ResolvableFuture<GltfModelResource> gltfModelResourceFuture = ResolvableFuture.create();
+        // TODO:b/374216912 - Consider calling setFuture() here to catch if the application calls
+        // cancel() on the return value from this function, so we can propagate the cancelation
+        // message
+        // to the Impress API.
+
+        if (!Looper.getMainLooper().isCurrentThread()) {
+            throw new IllegalStateException("This method must be called on the main thread.");
+        }
+
+        ListenableFuture<Long> gltfTokenFuture;
+        try {
+            gltfTokenFuture = impressApi.loadGltfModel(name);
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Failed to load glTF model with error: " + e.getMessage());
+            // TODO:b/375070346 - make this method NonNull and set the gltfModelResourceFuture to an
+            // exception and return that.
+            return null;
+        }
+
+        gltfTokenFuture.addListener(
+                () -> {
+                    try {
+                        long gltfToken = gltfTokenFuture.get();
+                        gltfModelResourceFuture.set(
+                                getModelResourceFromTokenSplitEngine(gltfToken));
+                    } catch (Exception e) {
+                        if (e instanceof InterruptedException) {
+                            Thread.currentThread().interrupt();
+                        }
+                        Log.e(TAG, "Failed to load glTF model with error: " + e.getMessage());
+                        gltfModelResourceFuture.setException(e);
+                    }
+                },
+                // It's convenient for the main application for us to dispatch their listeners on
+                // the main
+                // thread, because they are required to call back to Impress from there, and it's
+                // likely
+                // that they will want to call back into the SDK to create entities from within a
+                // listener.
+                // We defensively post to the main thread here, but in practice this should not
+                // cause a
+                // thread hop because the Impress API already dispatches its callbacks to the main
+                // thread.
+                activity::runOnUiThread);
+        return gltfModelResourceFuture;
+    }
+
+    // TODO: b/376504646 - Delete this method once we've migrated to a SplitEngine backed skybox.
+    @SuppressWarnings({
+        "AndroidJdkLibsChecker",
+        "RestrictTo",
+        "FutureReturnValueIgnored",
+        "AsyncSuffixFuture"
+    })
+    @Override
+    @Nullable
+    public ListenableFuture<ExrImageResource> loadExrImageByAssetName(@NonNull String assetName) {
+        ResolvableFuture<ExrImageResource> exrImageResourceFuture = ResolvableFuture.create();
+        InputStream asset;
+        try {
+            // NOTE: extensions.loadEnvironment expects a .EXR file.
+            asset = activity.getAssets().open(assetName);
+        } catch (Exception e) {
+            Log.w(TAG, "Could not open asset with error: " + e.getMessage());
+            return null;
+        }
+
+        CompletableFuture<androidx.xr.extensions.asset.EnvironmentToken> tokenFuture;
+        try {
+            // NOTE: At the moment, extensions.loadEnvironment expects a .EXR file explicitly. This
+            //       will need to be updated as support for GLTF environment geometry is added by
+            //       the system.
+            tokenFuture = extensions.loadEnvironment(asset, asset.available(), 0, assetName);
+            // Unfortunately, there is no way to avoid "leaking" this future, since we want to
+            // return a
+            // ListenableFuture. This method should be deleted soon, once the SplitEngine backed
+            // skybox
+            // is ready.
+            tokenFuture.thenApply(
+                    token -> exrImageResourceFuture.set(getExrImageResourceFromToken(token)));
+        } catch (Exception e) {
+            Log.i(TAG, "Could not load ExrImage with error: " + e.getMessage());
+            return null;
+        }
+        Log.w(TAG, "Loaded asset: " + assetName);
+
+        return exrImageResourceFuture;
+    }
+
+    @Override
+    @NonNull
+    public GltfEntity createGltfEntity(
+            @NonNull Pose pose, @NonNull GltfModelResource model, @Nullable Entity parentEntity) {
+        if (useSplitEngine && model instanceof GltfModelResourceImplSplitEngine) {
+            return createGltfEntitySplitEngine(pose, model, parentEntity);
+        }
+        if (parentEntity == null) {
+            throw new IllegalArgumentException("parentEntity cannot be null");
+        }
+        if (!(model instanceof GltfModelResourceImpl)) {
+            throw new IllegalArgumentException("GltfModelResource is not a GltfModelResourceImpl");
+        }
+        GltfEntity entity =
+                new GltfEntityImpl(
+                        (GltfModelResourceImpl) model,
+                        parentEntity,
+                        extensions,
+                        entityManager,
+                        executor);
+        entity.setPose(pose);
+        return entity;
+    }
+
+    @Override
+    @NonNull
+    public StereoSurfaceEntity createStereoSurfaceEntity(
+            @StereoSurfaceEntity.StereoMode int stereoMode,
+            @NonNull Dimensions dimensions,
+            @NonNull Pose pose,
+            @NonNull Entity parentEntity) {
+        if (useSplitEngine) {
+            return createStereoSurfaceEntitySplitEngine(stereoMode, dimensions, pose, parentEntity);
+        } else {
+            // TODO: b/377983872 - Throw UnsupportedOperationException here.
+            Log.w(TAG, "stereo surface only works with split engine.");
+            return new StereoSurfaceEntityImpl(parentEntity, extensions, entityManager, executor);
+        }
+    }
+
+    @Override
+    @NonNull
+    public PanelEntity createPanelEntity(
+            @NonNull Pose pose,
+            @NonNull View view,
+            @NonNull PixelDimensions surfaceDimensionsPx,
+            @NonNull Dimensions dimensions,
+            @NonNull String name,
+            @SuppressWarnings("ContextFirst") @NonNull Context context,
+            @NonNull Entity parent) {
+
+        // TODO(b/352630140):  Move this into a static factory method of PanelEntityImpl.
+
+        SurfaceControlViewHost surfaceControlViewHost =
+                new SurfaceControlViewHost(
+                        context, Objects.requireNonNull(context.getDisplay()), new Binder());
+        surfaceControlViewHost.setView(view, surfaceDimensionsPx.width, surfaceDimensionsPx.height);
+        Node node = extensions.createNode();
+        SurfacePackage surfacePackage =
+                Objects.requireNonNull(surfaceControlViewHost.getSurfacePackage());
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction
+                    .setName(node, name)
+                    .setSurfacePackage(node, surfacePackage)
+                    .setWindowBounds(
+                            surfacePackage, surfaceDimensionsPx.width, surfaceDimensionsPx.height)
+                    .setVisibility(node, true)
+                    // Corner radius must be zeroed as the value is inherited from the parent node
+                    // otherwise.
+                    .setCornerRadius(node, 0f)
+                    .apply();
+        }
+        surfacePackage.release();
+
+        PanelEntity panelEntity =
+                new PanelEntityImpl(
+                        node,
+                        extensions,
+                        entityManager,
+                        surfaceControlViewHost,
+                        surfaceDimensionsPx,
+                        executor);
+        panelEntity.setParent(parent);
+        panelEntity.setPose(pose);
+        return panelEntity;
+    }
+
+    @Override
+    @NonNull
+    public PanelEntity getMainPanelEntity() {
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setVisibility(taskWindowLeashNode, true).apply();
+        }
+        return mainPanelEntity;
+    }
+
+    @Override
+    @SuppressLint("ExecutorRegistration")
+    @NonNull
+    public InteractableComponent createInteractableComponent(
+            @NonNull Executor executor, @NonNull InputEventListener listener) {
+        return new InteractableComponentImpl(executor, listener);
+    }
+
+    @Override
+    @NonNull
+    public MovableComponent createMovableComponent(
+            boolean systemMovable,
+            boolean scaleInZ,
+            @NonNull Set<AnchorPlacement> anchorPlacement,
+            boolean shouldDisposeParentAnchor) {
+        return new MovableComponentImpl(
+                systemMovable,
+                scaleInZ,
+                anchorPlacement,
+                shouldDisposeParentAnchor,
+                perceptionLibrary,
+                extensions,
+                activitySpace,
+                (AndroidXrEntity) getActivitySpaceRootImpl(),
+                perceptionSpaceActivityPose,
+                entityManager,
+                new PanelShadowRenderer(
+                        activitySpace, perceptionSpaceActivityPose, activity, extensions),
+                executor);
+    }
+
+    @Override
+    @NonNull
+    public AnchorPlacement createAnchorPlacementForPlanes(
+            @NonNull Set<PlaneType> planeTypeFilter,
+            @NonNull Set<PlaneSemantic> planeSemanticFilter) {
+        AnchorPlacementImpl anchorPlacement = new AnchorPlacementImpl();
+        anchorPlacement.planeTypeFilter.addAll(planeTypeFilter);
+        anchorPlacement.planeSemanticFilter.addAll(planeSemanticFilter);
+        return anchorPlacement;
+    }
+
+    @Override
+    @NonNull
+    public ResizableComponent createResizableComponent(
+            @NonNull Dimensions minimumSize, @NonNull Dimensions maximumSize) {
+        return new ResizableComponentImpl(executor, extensions, minimumSize, maximumSize);
+    }
+
+    @Override
+    @SuppressLint("ExecutorRegistration")
+    @SuppressWarnings("ExecutorRegistration")
+    @NonNull
+    public PointerCaptureComponent createPointerCaptureComponent(
+            @NonNull Executor executor,
+            @NonNull PointerCaptureComponent.StateListener stateListener,
+            @NonNull InputEventListener inputListener) {
+        return new PointerCaptureComponentImpl(executor, stateListener, inputListener);
+    }
+
+    @Override
+    @NonNull
+    public ActivityPanelEntity createActivityPanelEntity(
+            @NonNull Pose pose,
+            @NonNull PixelDimensions windowBoundsPx,
+            @NonNull String name,
+            @NonNull Activity hostActivity,
+            @NonNull Entity parent) {
+
+        // TODO(b/352630140): Move this into a static factory method of ActivityPanelEntityImpl.
+        Rect windowBoundsRect = new Rect(0, 0, windowBoundsPx.width, windowBoundsPx.height);
+        ActivityPanel activityPanel =
+                extensions.createActivityPanel(
+                        hostActivity, new ActivityPanelLaunchParameters(windowBoundsRect));
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction
+                    .setVisibility(activityPanel.getNode(), true)
+                    .setName(activityPanel.getNode(), name)
+                    // Corner radius must be zeroed as the value is inherited from the parent node
+                    // otherwise.
+                    .setCornerRadius(activityPanel.getNode(), 0f)
+                    .apply();
+        }
+        activityPanel.setWindowBounds(windowBoundsRect);
+        ActivityPanelEntityImpl activityPanelEntity =
+                new ActivityPanelEntityImpl(
+                        activityPanel.getNode(),
+                        extensions,
+                        entityManager,
+                        activityPanel,
+                        windowBoundsPx,
+                        executor);
+        activityPanelEntity.setParent(parent);
+        activityPanelEntity.setPose(pose);
+        return activityPanelEntity;
+    }
+
+    @Override
+    @NonNull
+    public AnchorEntity createAnchorEntity(
+            @NonNull Dimensions bounds,
+            @NonNull PlaneType planeType,
+            @NonNull PlaneSemantic planeSemantic,
+            @NonNull Duration searchTimeout) {
+        Node node = extensions.createNode();
+        return AnchorEntityImpl.createSemanticAnchor(
+                node,
+                bounds,
+                planeType,
+                planeSemantic,
+                searchTimeout,
+                getActivitySpace(),
+                getActivitySpaceRootImpl(),
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    @Override
+    @NonNull
+    public AnchorEntity createAnchorEntity(@NonNull Anchor anchor) {
+        Node node = extensions.createNode();
+        return AnchorEntityImpl.createAnchorFromPerceptionAnchor(
+                node,
+                anchor,
+                getActivitySpace(),
+                getActivitySpaceRootImpl(),
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    @Override
+    @NonNull
+    public Entity createEntity(@NonNull Pose pose, @NonNull String name, @NonNull Entity parent) {
+        Node node = extensions.createNode();
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setName(node, name).apply();
+        }
+
+        // This entity is used to back JXR Core's ContentlessEntity.
+        Entity entity = new AndroidXrEntity(node, extensions, entityManager, executor) {};
+        entity.setParent(parent);
+        entity.setPose(pose);
+        return entity;
+    }
+
+    @Override
+    @NonNull
+    public AnchorEntity createPersistedAnchorEntity(
+            @NonNull UUID uuid, @NonNull Duration searchTimeout) {
+        Node node = extensions.createNode();
+        return AnchorEntityImpl.createPersistedAnchor(
+                node,
+                uuid,
+                searchTimeout,
+                getActivitySpace(),
+                getActivitySpaceRootImpl(),
+                extensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    @Override
+    public boolean unpersistAnchor(@NonNull UUID uuid) {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Cannot unpersist anchor, perception session is not initialized.");
+            return false;
+        }
+        return session.unpersistAnchor(uuid);
+    }
+
+    @Override
+    @NonNull
+    public Bundle setFullSpaceMode(@NonNull Bundle bundle) {
+        return extensions.setFullSpaceMode(bundle);
+    }
+
+    @Override
+    @NonNull
+    public Bundle setFullSpaceModeWithEnvironmentInherited(@NonNull Bundle bundle) {
+        return extensions.setFullSpaceModeWithEnvironmentInherited(bundle);
+    }
+
+    @Override
+    public void setPreferredAspectRatio(@NonNull Activity activity, float preferredRatio) {
+        // TODO: b/376934871 - Check async results.
+        extensions.setPreferredAspectRatio(activity, preferredRatio, (result) -> {}, Runnable::run);
+    }
+
+    @Override
+    public void startRenderer() {
+        if (splitEngineRenderer == null || frameLoopStarted) {
+            return;
+        }
+        frameLoopStarted = true;
+        splitEngineRenderer.startFrameLoop();
+    }
+
+    @Override
+    public void stopRenderer() {
+        if (splitEngineRenderer == null || !frameLoopStarted) {
+            return;
+        }
+        frameLoopStarted = false;
+        splitEngineRenderer.stopFrameLoop();
+    }
+
+    @Override
+    public void dispose() {
+        Log.i(TAG, "Disposing resources");
+        environment.dispose();
+        extensions.clearSpatialStateCallback(activity);
+        // TODO: b/376934871 - Check async results.
+        extensions.detachSpatialScene(activity, (result) -> {}, Runnable::run);
+        activity = null;
+        entityManager.getAllEntities().forEach(Entity::dispose);
+        entityManager.clear();
+        if (splitEngineRenderer != null && splitEngineSubspaceManager != null) {
+            splitEngineRenderer.destroy();
+            splitEngineSubspaceManager.destroy();
+        }
+    }
+
+    public void setSplitEngineSubspaceManager(
+            @Nullable SplitEngineSubspaceManager splitEngineSubspaceManager) {
+        this.splitEngineSubspaceManager = splitEngineSubspaceManager;
+    }
+
+    @Override
+    @NonNull
+    public SoundPoolExtensionsWrapper getSoundPoolExtensionsWrapper() {
+        return soundPoolExtensionsWrapper;
+    }
+
+    @Override
+    @NonNull
+    public AudioTrackExtensionsWrapper getAudioTrackExtensionsWrapper() {
+        return audioTrackExtensionsWrapper;
+    }
+
+    @Override
+    @NonNull
+    public MediaPlayerExtensionsWrapper getMediaPlayerExtensionsWrapper() {
+        return mediaPlayerExtensionsWrapper;
+    }
+
+    /**
+     * Get the underlying OpenXR session that backs perception.
+     *
+     * <p>The OpenXR session is created on JXR's primary thread so this may return {@code
+     * XR_NULL_HANDLE} for a few frames at startup.
+     *
+     * @return the OpenXR XrSession, encoded in a jlong
+     */
+    public long getNativeSession() {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Perception session is uninitilazied, returning XR_NULL_HANDLE");
+            return Session.XR_NULL_HANDLE;
+        }
+
+        long nativeSession = session.getNativeSession();
+        if (nativeSession == Session.XR_NULL_HANDLE) {
+            Log.w(TAG, "Perception session initialized, but native session not yet created");
+            return Session.XR_NULL_HANDLE;
+        }
+
+        return nativeSession;
+    }
+
+    /**
+     * Get the underlying OpenXR instance that backs perception.
+     *
+     * <p>The OpenXR instance is created on JXR's primary thread so this may return {@code
+     * XR_NULL_HANDLE} for a few frames at startup.
+     *
+     * @return the OpenXR XrInstance, encoded in a jlong
+     */
+    public long getNativeInstance() {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Perception session is uninitilazied, returning XR_NULL_HANDLE");
+            return Session.XR_NULL_HANDLE;
+        }
+
+        long nativeInstance = session.getNativeInstance();
+        if (nativeInstance == Session.XR_NULL_HANDLE) {
+            Log.w(TAG, "Perception session initialized, but native instance not yet created");
+            return Session.XR_NULL_HANDLE;
+        }
+
+        return nativeInstance;
+    }
+
+    private StereoSurfaceEntity createStereoSurfaceEntitySplitEngine(
+            @StereoSurfaceEntity.StereoMode int stereoMode,
+            Dimensions dimensions,
+            Pose pose,
+            @NonNull Entity parentEntity) {
+
+        if (!Looper.getMainLooper().isCurrentThread()) {
+            throw new IllegalStateException("This method must be called on the main thread.");
+        }
+
+        StereoSurfaceEntity entity =
+                new StereoSurfaceEntitySplitEngineImpl(
+                        parentEntity,
+                        impressApi,
+                        splitEngineSubspaceManager,
+                        extensions,
+                        entityManager,
+                        executor,
+                        stereoMode);
+        entity.setPose(pose);
+        entity.setDimensions(dimensions);
+        return entity;
+    }
+
+    private GltfEntity createGltfEntitySplitEngine(
+            Pose pose, GltfModelResource model, Entity parentEntity) {
+
+        if (!Looper.getMainLooper().isCurrentThread()) {
+            throw new IllegalStateException("This method must be called on the main thread.");
+        }
+        if (parentEntity == null) {
+            throw new IllegalArgumentException("parentEntity cannot be null");
+        }
+        if (!(model instanceof GltfModelResourceImplSplitEngine)) {
+            throw new IllegalArgumentException(
+                    "GltfModelResource is not a GltfModelResourceImplSplitEngine");
+        }
+        GltfEntity entity =
+                new GltfEntityImplSplitEngine(
+                        (GltfModelResourceImplSplitEngine) model,
+                        parentEntity,
+                        impressApi,
+                        splitEngineSubspaceManager,
+                        extensions,
+                        entityManager,
+                        executor);
+        entity.setPose(pose);
+        return entity;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/LoggingEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/LoggingEntityImpl.java
new file mode 100644
index 0000000..2c1487b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/LoggingEntityImpl.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivityPose;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.LoggingEntity;
+import androidx.xr.scenecore.common.BaseEntity;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Implementation of a RealityCore Entity that logs its function calls. */
+class LoggingEntityImpl extends BaseEntity implements LoggingEntity {
+
+    private static final String TAG = "RealityCoreRuntime";
+
+    public LoggingEntityImpl() {
+        Log.i(TAG, "Creating LoggingEntity.");
+    }
+
+    @Override
+    public Pose getPose() {
+        Log.i(TAG, "Getting Logging Entity pose: " + super.getPose());
+        return super.getPose();
+    }
+
+    @Override
+    public void setPose(Pose pose) {
+        Log.i(TAG, "Setting Logging Entity pose to: " + pose);
+        super.setPose(pose);
+    }
+
+    @Override
+    public Pose getActivitySpacePose() {
+        Log.i(TAG, "Getting Logging Entity activitySpacePose.");
+        return new Pose();
+    }
+
+    @Override
+    public Pose transformPoseTo(Pose pose, ActivityPose destination) {
+        Log.i(
+                TAG,
+                "Transforming pose "
+                        + pose
+                        + " to be relative to the destination ActivityPose: "
+                        + destination);
+        return new Pose();
+    }
+
+    @Override
+    public void addChild(Entity child) {
+        Log.i(TAG, "Adding child Entity: " + child);
+        super.addChild(child);
+    }
+
+    @Override
+    public void addChildren(List<Entity> children) {
+        Log.i(TAG, "Adding child Entities: " + children);
+        super.addChildren(children);
+    }
+
+    @Override
+    public Entity getParent() {
+        Log.i(TAG, "Getting Logging Entity parent: " + super.getParent());
+        return super.getParent();
+    }
+
+    @Override
+    public void setParent(Entity parent) {
+        if (!(parent instanceof LoggingEntityImpl)) {
+            Log.e(TAG, "Parent of a LoggingEntity must be a Logging entity");
+            return;
+        }
+        Log.i(TAG, "Setting Logging Entity parent to: " + parent);
+        super.setParent(parent);
+    }
+
+    @Override
+    public List<Entity> getChildren() {
+        Log.i(TAG, "Getting Logging Entity children: " + super.getChildren());
+        return super.getChildren();
+    }
+
+    @Override
+    public void setSize(Dimensions dimensions) {
+        Log.i(TAG, "Set size to " + dimensions);
+    }
+
+    @Override
+    public void addInputEventListener(Executor executor, InputEventListener consumer) {
+        Log.i(TAG, "Add input consumer " + consumer + " executor " + executor);
+    }
+
+    @Override
+    public void removeInputEventListener(InputEventListener consumer) {
+        Log.i(TAG, "Remove input consumer " + consumer);
+    }
+
+    @Override
+    public void dispose() {
+        Log.i(TAG, "dispose");
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MainPanelEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MainPanelEntityImpl.java
new file mode 100644
index 0000000..3d4c32a
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MainPanelEntityImpl.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.app.Activity;
+import android.graphics.Rect;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * MainPanelEntity is a special instance of a PanelEntity that is backed by the WindowLeash CPM
+ * node. The content of this PanelEntity is assumed to have been previously defined and associated
+ * with the Window Leash Node.
+ */
+@SuppressWarnings("deprecation") // TODO(b/373435470): Remove
+final class MainPanelEntityImpl extends BasePanelEntity implements PanelEntity {
+    Activity runtimeActivity;
+
+    // Note that we expect the Node supplied here to be the WindowLeash node.
+    MainPanelEntityImpl(
+            Activity activity,
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(node, extensions, entityManager, executor);
+        runtimeActivity = activity;
+
+        // Read the Pixel dimensions for the primary panel off the Activity's WindowManager.
+        //   Note that this requires MinAPI 30.
+        // TODO(b/352827267): Enforce minSDK API strategy - go/androidx-api-guidelines#compat-newapi
+        Rect bounds = getBoundsFromWindowManager();
+        super.setPixelDimensions(new PixelDimensions(bounds.width(), bounds.height()));
+    }
+
+    private Rect getBoundsFromWindowManager() {
+        return runtimeActivity.getWindowManager().getCurrentWindowMetrics().getBounds();
+    }
+
+    @Override
+    public Dimensions getSize() {
+        // The main panel bounds can change in HSM without JXRCore. Always read the bounds from the
+        // WindowManager.
+        Rect bounds = getBoundsFromWindowManager();
+        Vector3 pixelDensity = getPixelDensity();
+        return new Dimensions(
+                bounds.width() / pixelDensity.getX(), bounds.height() / pixelDensity.getY(), 0);
+    }
+
+    @Override
+    public PixelDimensions getPixelDimensions() {
+        // The main panel bounds can change in HSM without JXRCore. Always read the bounds from the
+        // WindowManager.
+        Rect bounds = getBoundsFromWindowManager();
+        return new PixelDimensions(bounds.width(), bounds.height());
+    }
+
+    @Override
+    public void setPixelDimensions(PixelDimensions dimensions) {
+        // TODO: b/376126162 - Consider calling setPixelDimensions() either when setMainWindowSize's
+        // callback is called, or when the next spatial state callback with the expected size is
+        // called.
+        super.setPixelDimensions(dimensions);
+        // TODO: b/376934871 - Check async results.
+        extensions.setMainWindowSize(
+                runtimeActivity,
+                dimensions.width,
+                dimensions.height,
+                (result) -> {},
+                Runnable::run);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/Matrix4Ext.kt b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/Matrix4Ext.kt
new file mode 100644
index 0000000..af6ba58
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/Matrix4Ext.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:JvmName("Matrix4Ext")
+
+package androidx.xr.scenecore.impl
+
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Vector3
+
+// TODO: b/377781580 - Consider removing this when rotation() can be used for scaled matrices.
+/** Returns the unscaled version of this matrix. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public fun Matrix4.getUnscaled(): Matrix4 {
+    // TODO: b/367780918 - Investigate why this.scale has negative values when inputs were positive.
+    // and allow negative scale values once this.scale reliably returns signed values.
+    val positiveScale = Vector3.abs(this.scale)
+    val scaleX = positiveScale.x
+    val scaleY = positiveScale.y
+    val scaleZ = positiveScale.z
+    return Matrix4(
+        floatArrayOf(
+            data[0] / scaleX,
+            data[1] / scaleX,
+            data[2] / scaleX,
+            data[3],
+            data[4] / scaleY,
+            data[5] / scaleY,
+            data[6] / scaleY,
+            data[7],
+            data[8] / scaleZ,
+            data[9] / scaleZ,
+            data[10] / scaleZ,
+            data[11],
+            data[12],
+            data[13],
+            data[14],
+            data[15],
+        )
+    )
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImpl.java
new file mode 100644
index 0000000..b741ef8
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImpl.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.media.MediaPlayer;
+
+import androidx.annotation.NonNull;
+import androidx.xr.extensions.media.MediaPlayerExtensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.MediaPlayerExtensionsWrapper;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes;
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes;
+
+/** Implementation of the {@link MediaPlayerExtensionsWrapper}. */
+final class MediaPlayerExtensionsWrapperImpl implements MediaPlayerExtensionsWrapper {
+
+    private final MediaPlayerExtensions extensions;
+
+    public MediaPlayerExtensionsWrapperImpl(@NonNull MediaPlayerExtensions extensions) {
+        this.extensions = extensions;
+    }
+
+    @Override
+    public void setPointSourceAttributes(
+            @NonNull MediaPlayer mediaPlayer, @NonNull PointSourceAttributes attributes) {
+        androidx.xr.extensions.media.PointSourceAttributes extAttributes =
+                MediaUtils.convertPointSourceAttributesToExtensions(attributes);
+
+        extensions.setPointSourceAttributes(mediaPlayer, extAttributes);
+    }
+
+    @Override
+    public void setSoundFieldAttributes(
+            @NonNull MediaPlayer mediaPlayer, @NonNull SoundFieldAttributes attributes) {
+        androidx.xr.extensions.media.SoundFieldAttributes extAttributes =
+                MediaUtils.convertSoundFieldAttributesToExtensions(attributes);
+
+        extensions.setSoundFieldAttributes(mediaPlayer, extAttributes);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaUtils.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaUtils.java
new file mode 100644
index 0000000..52010dc
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MediaUtils.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.annotation.RestrictTo;
+import androidx.xr.extensions.media.PointSourceAttributes;
+import androidx.xr.extensions.media.SoundFieldAttributes;
+import androidx.xr.extensions.media.SpatializerExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatializerConstants;
+
+/** Utils for the runtime media class conversions. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class MediaUtils {
+    private MediaUtils() {}
+
+    static PointSourceAttributes convertPointSourceAttributesToExtensions(
+            JxrPlatformAdapter.PointSourceAttributes attributes) {
+
+        Node node = ((AndroidXrEntity) attributes.getEntity()).getNode();
+
+        return new PointSourceAttributes.Builder().setNode(node).build();
+    }
+
+    static SoundFieldAttributes convertSoundFieldAttributesToExtensions(
+            JxrPlatformAdapter.SoundFieldAttributes attributes) {
+
+        return new SoundFieldAttributes.Builder()
+                .setAmbisonicsOrder(
+                        convertAmbisonicsOrderToExtensions(attributes.getAmbisonicsOrder()))
+                .build();
+    }
+
+    @SpatializerExtensions.AmbisonicsOrder
+    static int convertAmbisonicsOrderToExtensions(
+            @SpatializerConstants.AmbisonicsOrder int ambisonicsOrder) {
+        switch (ambisonicsOrder) {
+            case SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER:
+                return SpatializerExtensions.AMBISONICS_ORDER_FIRST_ORDER;
+            case SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER:
+                return SpatializerExtensions.AMBISONICS_ORDER_SECOND_ORDER;
+            case SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER:
+                return SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER;
+            default:
+                throw new IllegalArgumentException(
+                        "Invalid Sound Field ambisonics order: " + ambisonicsOrder);
+        }
+    }
+
+    @SpatializerConstants.SourceType
+    static int convertExtensionsToSourceType(
+            @SpatializerExtensions.SourceType int extensionsSourceType) {
+        switch (extensionsSourceType) {
+            case SpatializerExtensions.SOURCE_TYPE_BYPASS:
+                return SpatializerConstants.SOURCE_TYPE_BYPASS;
+            case SpatializerExtensions.SOURCE_TYPE_POINT_SOURCE:
+                return SpatializerConstants.SOURCE_TYPE_POINT_SOURCE;
+            case SpatializerExtensions.SOURCE_TYPE_SOUND_FIELD:
+                return SpatializerConstants.SOURCE_TYPE_SOUND_FIELD;
+            default:
+                throw new IllegalArgumentException(
+                        "Invalid Sound Spatializer source type: " + extensionsSourceType);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MovableComponentImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MovableComponentImpl.java
new file mode 100644
index 0000000..035bc3e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/MovableComponentImpl.java
@@ -0,0 +1,577 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static java.lang.Math.max;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.Consumer;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.MovableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.JxrPlatformAdapter.Ray;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.impl.perception.Plane.PlaneData;
+import androidx.xr.scenecore.impl.perception.Session;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+
+/** Implementation of MovableComponent. */
+@SuppressWarnings("BanConcurrentHashMap")
+class MovableComponentImpl implements MovableComponent {
+    private static final String TAG = "MovableComponentImpl";
+    static final float MIN_PLANE_ANCHOR_DISTANCE = .2f;
+    private final boolean systemMovable;
+    private final boolean scaleInZ;
+    private final boolean shouldDisposeParentAnchor;
+    private final PerceptionLibrary perceptionLibrary;
+    private final XrExtensions extensions;
+    private final ActivitySpaceImpl activitySpaceImpl;
+    private final AndroidXrEntity activitySpaceEntity;
+    private final PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose;
+    private final EntityManager entityManager;
+    private final PanelShadowRenderer panelShadowRenderer;
+    private final ScheduledExecutorService runtimeExecutor;
+    private final ConcurrentHashMap<MoveEventListener, Executor> moveEventListenersMap =
+            new ConcurrentHashMap<>();
+    private final Map<PlaneType, Map<PlaneSemantic, AnchorPlacementImpl>> anchorableFilters =
+            new EnumMap<>(PlaneType.class);
+    // Visible for testing.
+    Consumer<ReformEvent> reformEventConsumer;
+    private Entity entity;
+    private Entity initialParent;
+    private Pose lastPose = new Pose();
+    private Vector3 lastScale = new Vector3(1f, 1f, 1f);
+    private Dimensions currentSize;
+    private boolean userAnchorable = false;
+    private AnchorEntity createdAnchorEntity;
+    private AnchorPlacementImpl createdAnchorPlacement;
+    @ScaleWithDistanceMode private int scaleWithDistanceMode = ScaleWithDistanceMode.DEFAULT;
+
+    public MovableComponentImpl(
+            boolean systemMovable,
+            boolean scaleInZ,
+            Set<AnchorPlacement> anchorPlacement,
+            boolean shouldDisposeParentAnchor,
+            PerceptionLibrary perceptionLibrary,
+            XrExtensions extensions,
+            ActivitySpaceImpl activitySpaceImpl,
+            AndroidXrEntity activitySpaceEntity,
+            PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose,
+            EntityManager entityManager,
+            PanelShadowRenderer panelShadowRenderer,
+            ScheduledExecutorService runtimeExecutor) {
+        this.systemMovable = systemMovable;
+        this.scaleInZ = scaleInZ;
+        this.shouldDisposeParentAnchor = shouldDisposeParentAnchor;
+        this.perceptionLibrary = perceptionLibrary;
+        this.extensions = extensions;
+        this.activitySpaceImpl = activitySpaceImpl;
+        this.activitySpaceEntity = activitySpaceEntity;
+        this.perceptionSpaceActivityPose = perceptionSpaceActivityPose;
+        this.entityManager = entityManager;
+        this.panelShadowRenderer = panelShadowRenderer;
+        this.runtimeExecutor = runtimeExecutor;
+        setUpAnchorPlacement(anchorPlacement);
+    }
+
+    @Override
+    public boolean onAttach(Entity entity) {
+        if (this.entity != null) {
+            Log.e(TAG, "Already attached to entity " + this.entity);
+            return false;
+        }
+        this.entity = entity;
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        // The math for anchoring uses the pose relative to the activity space so we should not set
+        // the reform options to be relative to the parent if the entity is anchorable.
+        int reformFlags = userAnchorable ? 0 : ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT;
+        reformFlags =
+                (systemMovable && !userAnchorable)
+                        ? reformFlags | ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT
+                        : reformFlags;
+        reformFlags = scaleInZ ? reformFlags | ReformOptions.FLAG_SCALE_WITH_DISTANCE : reformFlags;
+        reformOptions.setFlags(reformFlags);
+        reformOptions.setEnabledReform(reformOptions.getEnabledReform() | ReformOptions.ALLOW_MOVE);
+        reformOptions.setScaleWithDistanceMode(
+                translateScaleWithDistanceMode(scaleWithDistanceMode));
+
+        // TODO: b/348037292 - Remove this special case for PanelEntityImpl.
+        if (entity instanceof PanelEntityImpl && currentSize == null) {
+            currentSize = ((PanelEntityImpl) entity).getSize();
+        }
+        if (currentSize != null) {
+            reformOptions.setCurrentSize(
+                    new Vec3(currentSize.width, currentSize.height, currentSize.depth));
+        }
+        if (userAnchorable && systemMovable && reformEventConsumer == null) {
+            reformEventConsumer =
+                    reformEvent -> {
+                        Pair<Pose, Entity> unused = getUpdatedReformEventPoseAndParent(reformEvent);
+                    };
+        }
+        lastPose = entity.getPose();
+        lastScale = entity.getScale();
+        ((AndroidXrEntity) entity).updateReformOptions();
+        if (reformEventConsumer != null) {
+            ((AndroidXrEntity) entity).addReformEventConsumer(reformEventConsumer, runtimeExecutor);
+        }
+        return true;
+    }
+
+    @Override
+    public void onDetach(Entity entity) {
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setEnabledReform(
+                reformOptions.getEnabledReform() & ~ReformOptions.ALLOW_MOVE);
+        // Clear any flags that were set by this component.
+        int reformFlags = reformOptions.getFlags();
+        reformFlags =
+                systemMovable
+                        ? reformFlags & ~ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT
+                        : reformFlags;
+        reformFlags =
+                scaleInZ ? reformFlags & ~ReformOptions.FLAG_SCALE_WITH_DISTANCE : reformFlags;
+        reformOptions.setFlags(reformFlags);
+        ((AndroidXrEntity) entity).updateReformOptions();
+        if (reformEventConsumer != null) {
+            ((AndroidXrEntity) entity).removeReformEventConsumer(reformEventConsumer);
+            reformEventConsumer = null;
+        }
+        this.entity = null;
+    }
+
+    @Override
+    public void setSize(Dimensions dimensions) {
+        currentSize = dimensions;
+        if (entity == null) {
+            Log.i(TAG, "setSize called before component is attached to an Entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setCurrentSize(
+                new Vec3(dimensions.width, dimensions.height, dimensions.depth));
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    @ScaleWithDistanceMode
+    public int getScaleWithDistanceMode() {
+        return scaleWithDistanceMode;
+    }
+
+    @Override
+    public void setScaleWithDistanceMode(@ScaleWithDistanceMode int scaleWithDistanceMode) {
+        this.scaleWithDistanceMode = scaleWithDistanceMode;
+        if (entity == null) {
+            Log.w(
+                    TAG,
+                    "setScaleWithDistanceMode called before component is attached to an Entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setScaleWithDistanceMode(
+                translateScaleWithDistanceMode(scaleWithDistanceMode));
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    public void addMoveEventListener(Executor executor, MoveEventListener moveEventListener) {
+        if (reformEventConsumer != null) {
+            ((AndroidXrEntity) entity).removeReformEventConsumer(reformEventConsumer);
+        }
+        reformEventConsumer =
+                reformEvent -> {
+                    if (reformEvent.getType() != ReformEvent.REFORM_TYPE_MOVE) {
+                        return;
+                    }
+                    if (reformEvent.getState() == ReformEvent.REFORM_STATE_START) {
+                        initialParent = entity.getParent();
+                    }
+                    Pose newPose;
+                    Entity updatedParent = null;
+                    if (userAnchorable) {
+                        Pair<Pose, Entity> updatedPoseParentPair =
+                                getUpdatedReformEventPoseAndParent(reformEvent);
+                        newPose = updatedPoseParentPair.first;
+                        updatedParent = updatedPoseParentPair.second;
+                    } else {
+                        newPose =
+                                RuntimeUtils.getPose(
+                                        reformEvent.getProposedPosition(),
+                                        reformEvent.getProposedOrientation());
+                    }
+                    Vector3 newScale = RuntimeUtils.getVector3(reformEvent.getProposedScale());
+                    Entity disposeEntity = null;
+
+                    Entity parent = updatedParent;
+                    moveEventListenersMap.forEach(
+                            (listener, listenerExecutor) ->
+                                    executor.execute(
+                                            () ->
+                                                    listener.onMoveEvent(
+                                                            new MoveEvent(
+                                                                    reformEvent.getState(),
+                                                                    new Ray(
+                                                                            RuntimeUtils.getVector3(
+                                                                                    reformEvent
+                                                                                            .getInitialRayOrigin()),
+                                                                            RuntimeUtils.getVector3(
+                                                                                    reformEvent
+                                                                                            .getInitialRayDirection())),
+                                                                    new Ray(
+                                                                            RuntimeUtils.getVector3(
+                                                                                    reformEvent
+                                                                                            .getCurrentRayOrigin()),
+                                                                            RuntimeUtils.getVector3(
+                                                                                    reformEvent
+                                                                                            .getCurrentRayDirection())),
+                                                                    lastPose,
+                                                                    newPose,
+                                                                    lastScale,
+                                                                    newScale,
+                                                                    initialParent,
+                                                                    parent,
+                                                                    disposeEntity))));
+                    lastPose = newPose;
+                    lastScale = newScale;
+                };
+        moveEventListenersMap.put(moveEventListener, executor);
+        if (entity == null) {
+            Log.i(TAG, "setMoveEventListener called before component is attached to an Entity.");
+            return;
+        }
+        ((AndroidXrEntity) entity).addReformEventConsumer(reformEventConsumer, executor);
+    }
+
+    private void setUpAnchorPlacement(Set<AnchorPlacement> anchorPlacement) {
+
+        for (AnchorPlacement placement : anchorPlacement) {
+            if (!(placement instanceof AnchorPlacementImpl)) {
+                continue;
+            }
+            AnchorPlacementImpl placementImpl = (AnchorPlacementImpl) placement;
+            Map<PlaneSemantic, AnchorPlacementImpl> anchorablePlaneSemantic =
+                    new EnumMap<>(PlaneSemantic.class);
+            for (PlaneSemantic planeSemantic : placementImpl.planeSemanticFilter) {
+                anchorablePlaneSemantic.put(planeSemantic, placementImpl);
+            }
+            for (PlaneType planeType : placementImpl.planeTypeFilter) {
+                this.anchorableFilters.put(planeType, anchorablePlaneSemantic);
+            }
+        }
+        if (!anchorableFilters.isEmpty()) {
+            this.userAnchorable = true;
+        }
+    }
+
+    @Override
+    public void removeMoveEventListener(MoveEventListener moveEventListener) {
+        moveEventListenersMap.remove(moveEventListener);
+    }
+
+    private Pair<Pose, Entity> getUpdatedReformEventPoseAndParent(ReformEvent reformEvent) {
+        if (reformEvent.getState() == ReformEvent.REFORM_STATE_END && shouldRenderPlaneShadow()) {
+            panelShadowRenderer.destroy();
+        }
+        Pose proposedPose =
+                RuntimeUtils.getPose(
+                        reformEvent.getProposedPosition(), reformEvent.getProposedOrientation());
+        Pair<Pose, Entity> updatedEntity = updatePoseWithPlanes(proposedPose, reformEvent);
+        if (systemMovable) {
+            entity.setPose(updatedEntity.first);
+        }
+        return updatedEntity;
+    }
+
+    private Pair<Pose, Entity> updatePoseWithPlanes(Pose proposedPose, ReformEvent reformEvent) {
+        Session session = perceptionLibrary.getSession();
+        if (session == null) {
+            Log.w(TAG, "Unable to load perception session, cannot anchor object to a plane.");
+            return Pair.create(proposedPose, null);
+        }
+        List<Plane> planes = session.getAllPlanes();
+        if (planes.isEmpty()) {
+            return Pair.create(proposedPose, null);
+        }
+
+        // The proposed pose is relative to the activity space, it needs to be updated to be in the
+        // perception reference space to be compared against the planes..
+        Pose updatedPoseInOpenXr =
+                activitySpaceImpl.transformPoseTo(proposedPose, perceptionSpaceActivityPose);
+
+        // Create variables to store the plane in case we need to anchor to it later.
+        Plane anchorablePlane = null;
+        PlaneData anchorablePlaneData = null;
+        AnchorPlacementImpl anchorPlacement = null;
+        Long dataTimeNs = SystemClock.uptimeMillis() * 1000000;
+
+        // Update the pose based on all the known planes.
+        for (Plane plane : planes) {
+
+            PlaneData planeData = plane.getData(dataTimeNs);
+            if (planeData == null) {
+                continue;
+            }
+            Pose planePoseUpdate = updatePoseForPlane(planeData, updatedPoseInOpenXr);
+            if (planePoseUpdate == null) {
+                continue;
+            }
+            AnchorPlacementImpl newAnchorPlacement = getAnchorPlacementIfAnchorable(planeData);
+            if (newAnchorPlacement != null) {
+                anchorablePlane = plane;
+                anchorablePlaneData = planeData;
+                anchorPlacement = newAnchorPlacement;
+            }
+            updatedPoseInOpenXr = planePoseUpdate;
+        }
+
+        if (anchorablePlane != null) {
+            // If the reform state is end, we should try to anchor the entity to the plane.
+            if (reformEvent.getState() == ReformEvent.REFORM_STATE_END) {
+                Pair<Pose, AnchorEntity> resultPair =
+                        anchorEntityToPlane(
+                                updatedPoseInOpenXr,
+                                anchorablePlane,
+                                anchorablePlaneData,
+                                anchorPlacement,
+                                dataTimeNs);
+                if (resultPair.second != null) {
+                    return Pair.create(resultPair.first, (Entity) resultPair.second);
+                }
+            } else {
+                // Otherwise, we should try to render the plane shadow onto the plane.
+                tryRenderPlaneShadow(
+                        updatedPoseInOpenXr,
+                        RuntimeUtils.fromPerceptionPose(anchorablePlaneData.centerPose));
+            }
+        } else if (shouldRenderPlaneShadow()) {
+            // If there is nothing to anchor to, hide the plane shadow.
+            panelShadowRenderer.hidePlane();
+        }
+
+        // If the entity was anchored and the reform is complete, update the entity to be in the
+        // activity space and remove the previously created anchor data. If
+        // shouldDisposeParentAnchor is
+        // true dispose the previously created anchor entity.
+        if (createdAnchorEntity != null
+                && entity.getParent() == createdAnchorEntity
+                && reformEvent.getState() == ReformEvent.REFORM_STATE_END) {
+
+            entity.setScale(
+                    entity.getWorldSpaceScale().div(activitySpaceImpl.getWorldSpaceScale()));
+            entity.setParent(activitySpaceImpl);
+            checkAndDisposeAnchorEntity();
+            createdAnchorEntity = null;
+            createdAnchorPlacement = null;
+
+            // Move the updated pose back to the activity space.
+            Pose updatedPoseInActivitySpace =
+                    perceptionSpaceActivityPose.transformPoseTo(
+                            updatedPoseInOpenXr, activitySpaceImpl);
+            return Pair.create(updatedPoseInActivitySpace, activitySpaceImpl);
+        }
+
+        // If the entity has a parent, transform the pose to the parent's space.
+        Entity parent = entity.getParent();
+        if (parent == null || parent == activitySpaceImpl) {
+            return Pair.create(
+                    perceptionSpaceActivityPose.transformPoseTo(
+                            updatedPoseInOpenXr, activitySpaceImpl),
+                    null);
+        }
+
+        return Pair.create(
+                perceptionSpaceActivityPose.transformPoseTo(updatedPoseInOpenXr, parent), null);
+    }
+
+    // Gets the anchor placement settings for the given plane data, if it is null the entity should
+    // not be anchored to this plane.
+    @Nullable
+    private AnchorPlacementImpl getAnchorPlacementIfAnchorable(PlaneData planeData) {
+        if (!userAnchorable || !systemMovable) {
+            return null;
+        }
+        Map<PlaneSemantic, AnchorPlacementImpl> anchorablePlaneSemantic =
+                anchorableFilters.get(RuntimeUtils.getPlaneType(planeData.type));
+        if (anchorablePlaneSemantic != null) {
+            if (anchorablePlaneSemantic.containsKey(
+                    RuntimeUtils.getPlaneSemantic(planeData.label))) {
+                return anchorablePlaneSemantic.get(RuntimeUtils.getPlaneSemantic(planeData.label));
+            } else if (anchorablePlaneSemantic.containsKey(PlaneSemantic.ANY)) {
+                return anchorablePlaneSemantic.get(PlaneSemantic.ANY);
+            }
+        }
+        anchorablePlaneSemantic = anchorableFilters.get(PlaneType.ANY);
+        if (anchorablePlaneSemantic != null) {
+            if (anchorablePlaneSemantic.containsKey(
+                    RuntimeUtils.getPlaneSemantic(planeData.label))) {
+                return anchorablePlaneSemantic.get(RuntimeUtils.getPlaneSemantic(planeData.label));
+            } else if (anchorablePlaneSemantic.containsKey(PlaneSemantic.ANY)) {
+                return anchorablePlaneSemantic.get(PlaneSemantic.ANY);
+            }
+        }
+        return null;
+    }
+
+    private Pair<Pose, AnchorEntity> anchorEntityToPlane(
+            Pose updatedPose,
+            Plane plane,
+            PlaneData anchorablePlaneData,
+            AnchorPlacementImpl anchorPlacement,
+            Long dataTimeNs) {
+        AnchorEntityImpl anchorEntity =
+                AnchorEntityImpl.createAnchorFromPlane(
+                        extensions.createNode(),
+                        plane,
+                        new Pose(),
+                        dataTimeNs,
+                        activitySpaceImpl,
+                        activitySpaceEntity,
+                        extensions,
+                        entityManager,
+                        runtimeExecutor,
+                        perceptionLibrary);
+        if (anchorEntity.getState() != AnchorEntityImpl.State.ANCHORED) {
+            return Pair.create(updatedPose, null);
+        }
+
+        // TODO: b/367754233: Fix the flashing when parented to a new anchor.
+        // Check the scale of the entity before the move so we can rescale when we move it to the
+        // AnchorEntity. Note the AnchorEntity has a scale of 1 so we don't need to also scale by
+        // the
+        // anchor entity's scale.
+        Vector3 entityScale = entity.getWorldSpaceScale();
+        entity.setScale(entityScale);
+        Quaternion planeRotation =
+                RuntimeUtils.fromPerceptionPose(anchorablePlaneData.centerPose).getRotation();
+        Pose rotatedPose =
+                new Pose(
+                        updatedPose.getTranslation(),
+                        PlaneUtils.rotateEntityToPlane(updatedPose.getRotation(), planeRotation));
+
+        Pose planeCenterPose = RuntimeUtils.fromPerceptionPose(anchorablePlaneData.centerPose);
+        Pose poseToAnchor = planeCenterPose.getInverse().compose(rotatedPose);
+        poseToAnchor =
+                new Pose(
+                        new Vector3(
+                                poseToAnchor.getTranslation().getX(),
+                                0f,
+                                poseToAnchor.getTranslation().getZ()),
+                        poseToAnchor.getRotation());
+        entity.setParent(anchorEntity);
+        // If the anchor placement settings specify that the anchor should be disposed, dispose of
+        // the
+        // previously created anchor entity.
+        checkAndDisposeAnchorEntity();
+        createdAnchorEntity = anchorEntity;
+        createdAnchorPlacement = anchorPlacement;
+        return Pair.create(poseToAnchor, anchorEntity);
+    }
+
+    @Nullable
+    private Pose updatePoseForPlane(PlaneData planeData, Pose proposedPoseInOpenXr) {
+        // Get the pose as related to the center of the plane.
+
+        Pose centerPose = RuntimeUtils.fromPerceptionPose(planeData.centerPose);
+        Pose centerPoseToProposedPose = centerPose.getInverse().compose(proposedPoseInOpenXr);
+
+        // The extents of the plane are in the X and Z directions so we can use those to determine
+        // if
+        // the point is outside the plane.
+        if (centerPoseToProposedPose.getTranslation().getX() < -planeData.extentWidth
+                || centerPoseToProposedPose.getTranslation().getX() > planeData.extentWidth
+                || centerPoseToProposedPose.getTranslation().getZ() < -planeData.extentHeight
+                || centerPoseToProposedPose.getTranslation().getZ() > planeData.extentHeight) {
+            return null;
+        }
+
+        // The distance between the point and the plane. If it is less than the minimum allowed
+        // distance, move it to the plane. We only need to take the y-value because the y-value is
+        // normal to the plane. We established above that the point is within the extents of the
+        // plane.
+        float distance = centerPoseToProposedPose.getTranslation().getY();
+        if (distance >= MIN_PLANE_ANCHOR_DISTANCE) {
+            return null;
+        }
+        centerPoseToProposedPose =
+                new Pose(
+                        new Vector3(
+                                centerPoseToProposedPose.getTranslation().getX(),
+                                max(0f, distance),
+                                centerPoseToProposedPose.getTranslation().getZ()),
+                        centerPoseToProposedPose.getRotation());
+        return centerPose.compose(centerPoseToProposedPose);
+    }
+
+    private void tryRenderPlaneShadow(Pose proposedPose, Pose planePose) {
+        if (!shouldRenderPlaneShadow()) {
+            return;
+        }
+        panelShadowRenderer.updatePanelPose(proposedPose, planePose, (PanelEntityImpl) entity);
+    }
+
+    private boolean shouldRenderPlaneShadow() {
+        return entity instanceof PanelEntityImpl && systemMovable;
+    }
+
+    // Checks if there is a created anchor entity and if it should be disposed. If so, disposes of
+    // the
+    // anchor entity. Resets the createdAnchorEntity and createdAnchorPlacement to null.
+    private void checkAndDisposeAnchorEntity() {
+        if (createdAnchorEntity != null
+                && createdAnchorEntity.getChildren().isEmpty()
+                && createdAnchorPlacement != null
+                && shouldDisposeParentAnchor) {
+            createdAnchorEntity.dispose();
+        }
+    }
+
+    private static @ReformOptions.ScaleWithDistanceMode int translateScaleWithDistanceMode(
+            @ScaleWithDistanceMode int scale) {
+        switch (scale) {
+            case ScaleWithDistanceMode.DMM:
+                return ReformOptions.SCALE_WITH_DISTANCE_MODE_DMM;
+            default:
+                return ReformOptions.SCALE_WITH_DISTANCE_MODE_DEFAULT;
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/OpenXrActivityPoseHelper.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/OpenXrActivityPoseHelper.java
new file mode 100644
index 0000000..b977948
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/OpenXrActivityPoseHelper.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+
+/**
+ * A helper class for converting poses from an OpenXR pose to a pose in the activity space or world
+ * space.
+ */
+final class OpenXrActivityPoseHelper {
+    private static final String TAG = "OpenXrPoseHelper";
+    private final ActivitySpaceImpl activitySpace;
+    private final AndroidXrEntity activitySpaceRoot;
+
+    public OpenXrActivityPoseHelper(
+            ActivitySpaceImpl activitySpace, AndroidXrEntity activitySpaceRoot) {
+        this.activitySpace = activitySpace;
+        this.activitySpaceRoot = activitySpaceRoot;
+    }
+
+    /**
+     * Returns the pose relative to the activity space by transforming with the OpenXR reference
+     * space. If there is an error retrieving the openXR reference space, this will return the
+     * identity pose.
+     */
+    public Pose getPoseInActivitySpace(Pose openXrToPose) {
+        if (activitySpace == null) {
+            Log.e(TAG, "Cannot get pose in Activity Space with a null Activity Space.");
+            return new Pose();
+        }
+
+        // ActivitySpace and the ActivityPose should have unit scale and the ActivityPose should
+        // have no
+        // direct parent so we can just compose the two poses without scaling.
+        final Pose openXrToActivitySpace = activitySpace.getPoseInOpenXrReferenceSpace();
+        // TODO: b/353575470 throw an exception here instead of returning identity pose.
+        if (openXrToActivitySpace == null || openXrToPose == null) {
+            Log.e(
+                    TAG,
+                    "Cannot retrieve pose in underlying space for the ActivityPose. Returning"
+                            + " identity pose.");
+            return new Pose();
+        }
+
+        final Pose activitySpaceToOpenXr = openXrToActivitySpace.getInverse();
+        return activitySpaceToOpenXr.compose(openXrToPose);
+    }
+
+    /** Returns the ActivityPose's pose in the activity space. */
+    public Pose getActivitySpacePose(Pose openXrToPose) {
+        if (activitySpaceRoot == null) {
+            Log.e(TAG, "Cannot get pose in World Space Pose with a null World Space Entity.");
+            return new Pose();
+        }
+
+        // ActivitySpace and the nodeless entity have unit scale and the nodeless entity has no
+        // direct
+        // parent so we can just compose the two poses without scaling.
+        final Pose activitySpaceToPose = this.getPoseInActivitySpace(openXrToPose);
+        final Pose worldSpaceToActivitySpace =
+                activitySpaceRoot.getPoseInActivitySpace().getInverse();
+        return worldSpaceToActivitySpace.compose(activitySpaceToPose);
+    }
+
+    /** Returns the scale of the WorldPose with respect to the activity space. */
+    public Vector3 getActivitySpaceScale(Vector3 openXrScale) {
+        if (activitySpace == null) {
+            Log.e(TAG, "Cannot get scale in Activity Space with a null Activity Space Entity.");
+            return new Vector3(1f, 1f, 1f);
+        }
+        return openXrScale.div(activitySpace.getWorldSpaceScale());
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelEntityImpl.java
new file mode 100644
index 0000000..cab3f98
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelEntityImpl.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+import android.view.SurfaceControlViewHost;
+import android.view.SurfaceControlViewHost.SurfacePackage;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+
+import java.util.Objects;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of PanelEntity.
+ *
+ * <p>(Requires API Level 30)
+ *
+ * <p>This entity shows 2D view on spatial panel.
+ */
+final class PanelEntityImpl extends BasePanelEntity implements PanelEntity {
+    private static final String TAG = "PanelEntity";
+    private final SurfaceControlViewHost surfaceControlViewHost;
+
+    // TODO(b/352630140): Create a static factory method for PanelEntityImpl and move the Extensions
+    //                    init there (out of JxrPlatformAdapterAxr)
+
+    public PanelEntityImpl(
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            SurfaceControlViewHost surfaceControlViewHost,
+            PixelDimensions windowBoundsPx,
+            ScheduledExecutorService executor) {
+        super(node, extensions, entityManager, executor);
+        // We need to manually inform our base class of the pixelDimensions, even though the
+        // Extensions
+        // are initialized in the factory method. (ext.setWindowBounds, etc)
+        super.setPixelDimensions(windowBoundsPx);
+        this.surfaceControlViewHost = surfaceControlViewHost;
+    }
+
+    // TODO(b/352827267): Enforce minSDK API strategy - go/androidx-api-guidelines#compat-newapi
+    @Override
+    public void setPixelDimensions(PixelDimensions dimensions) {
+        super.setPixelDimensions(dimensions);
+
+        SurfacePackage surfacePackage =
+                Objects.requireNonNull(surfaceControlViewHost.getSurfacePackage());
+
+        surfaceControlViewHost.relayout(dimensions.width, dimensions.height);
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction
+                    .setWindowBounds(surfacePackage, dimensions.width, dimensions.height)
+                    .apply();
+        }
+        surfacePackage.release();
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        Log.i(TAG, "Disposing " + this);
+        surfaceControlViewHost.release();
+        super.dispose();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelShadowRenderer.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelShadowRenderer.java
new file mode 100644
index 0000000..9d5393e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PanelShadowRenderer.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.SurfaceControlViewHost;
+import android.view.SurfaceControlViewHost.SurfacePackage;
+import android.view.View;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+
+import java.util.Objects;
+
+/** Class for rendering the border of a panel onto a perception plane. */
+class PanelShadowRenderer {
+    private static final float STROKE_WIDTH = 20f;
+    private static final float HALF_STROKE_WIDTH = STROKE_WIDTH / 2;
+    private static final float CORNER_RADIUS = 20f;
+    private static final float PANEL_BORDER_ADDED_MARGIN = 50f;
+    private final ActivitySpaceImpl activitySpaceImpl;
+    private final PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose;
+    private final XrExtensions extensions;
+    private SurfaceControlViewHost surfaceControlViewHost;
+    private final Handler handler;
+    private boolean isVisible;
+    private final Activity activity;
+    Node panelShadowNode;
+
+    /** PanelShadowView is a view with a blue border to enable the shadow effect. */
+    private static class PanelShadowView extends View {
+
+        public PanelShadowView(Context context) {
+            super(context);
+        }
+
+        @Override
+        public void onDrawForeground(Canvas canvas) {
+            super.onDrawForeground(canvas);
+            Path border = new Path();
+            border.addRoundRect(
+                    HALF_STROKE_WIDTH,
+                    HALF_STROKE_WIDTH,
+                    canvas.getWidth() - HALF_STROKE_WIDTH,
+                    canvas.getHeight() - HALF_STROKE_WIDTH,
+                    CORNER_RADIUS,
+                    CORNER_RADIUS,
+                    Path.Direction.CW);
+            Paint paint = new Paint();
+            paint.setColor(0xFF54639F);
+            paint.setStyle(Paint.Style.STROKE);
+            paint.setStrokeWidth(STROKE_WIDTH);
+            canvas.drawPath(border, paint);
+        }
+    }
+
+    public PanelShadowRenderer(
+            ActivitySpaceImpl activitySpaceImpl,
+            PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose,
+            Activity activity,
+            XrExtensions extensions) {
+        this.activitySpaceImpl = activitySpaceImpl;
+        this.perceptionSpaceActivityPose = perceptionSpaceActivityPose;
+        this.extensions = extensions;
+        this.activity = activity;
+        this.handler = new Handler(Looper.getMainLooper());
+    }
+
+    void updatePanelPose(
+            Pose openXrToProposedPanel, Pose openXrtoPlane, PanelEntityImpl panelEntity) {
+        // If there is no panel shadow node, create it.
+        if (panelShadowNode == null) {
+            createPanelShadow(openXrToProposedPanel, openXrtoPlane, panelEntity);
+            return;
+        }
+
+        Pose panelPoseInActivitySpace =
+                getUpdatedPanelPoseInActivitySpace(openXrToProposedPanel, openXrtoPlane);
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            if (!isVisible) {
+                transaction.setVisibility(panelShadowNode, true);
+                isVisible = true;
+            }
+            transaction
+                    .setPosition(
+                            panelShadowNode,
+                            panelPoseInActivitySpace.getTranslation().getX(),
+                            panelPoseInActivitySpace.getTranslation().getY(),
+                            panelPoseInActivitySpace.getTranslation().getZ())
+                    .setOrientation(
+                            panelShadowNode,
+                            panelPoseInActivitySpace.getRotation().getX(),
+                            panelPoseInActivitySpace.getRotation().getY(),
+                            panelPoseInActivitySpace.getRotation().getZ(),
+                            panelPoseInActivitySpace.getRotation().getW())
+                    .apply();
+        }
+    }
+
+    void hidePlane() {
+        if (!isVisible) {
+            return;
+        }
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            transaction.setVisibility(panelShadowNode, false).apply();
+        }
+        isVisible = false;
+    }
+
+    void destroy() {
+        if (surfaceControlViewHost != null) {
+            handler.post(() -> surfaceControlViewHost.release());
+        }
+        if (panelShadowNode != null) {
+            try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+                transaction.setParent(panelShadowNode, null).apply();
+            }
+        }
+        panelShadowNode = null;
+    }
+
+    private void createPanelShadow(
+            Pose openXrToProposedPanel, Pose openXrtoPlane, PanelEntityImpl panelEntity) {
+        View view = new PanelShadowView(activity);
+
+        // Scale the panel shadow to the size of the PanelEntity in the activity space.
+        Vector3 entityScale = panelEntity.getWorldSpaceScale();
+        float sizeX =
+                panelEntity.getPixelDimensions().width
+                                * entityScale.getX()
+                                / activitySpaceImpl.getWorldSpaceScale().getX()
+                        + PANEL_BORDER_ADDED_MARGIN;
+        float sizeZ =
+                panelEntity.getPixelDimensions().height
+                                * entityScale.getZ()
+                                / activitySpaceImpl.getWorldSpaceScale().getX()
+                        + PANEL_BORDER_ADDED_MARGIN;
+
+        Pose panelPoseInActivitySpace =
+                getUpdatedPanelPoseInActivitySpace(openXrToProposedPanel, openXrtoPlane);
+
+        panelShadowNode = extensions.createNode();
+
+        // The surfaceControlViewHost needs to be created on the main thread.
+        // TODO(b/352827267): Enforce minSDK API strategy - go/androidx-api-guidelines#compat-newapi
+        handler.post(
+                () -> {
+                    surfaceControlViewHost =
+                            new SurfaceControlViewHost(
+                                    activity,
+                                    Objects.requireNonNull(activity.getDisplay()),
+                                    new Binder());
+                    surfaceControlViewHost.setView(view, (int) sizeX, (int) sizeZ);
+                    SurfacePackage surfacePackage =
+                            Objects.requireNonNull(surfaceControlViewHost.getSurfacePackage());
+                    try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+                        transaction
+                                .setName(panelShadowNode, "PanelRenderer")
+                                .setSurfacePackage(panelShadowNode, surfacePackage)
+                                .setWindowBounds(surfacePackage, (int) sizeX, (int) sizeZ)
+                                .setVisibility(panelShadowNode, true)
+                                .setPosition(
+                                        panelShadowNode,
+                                        panelPoseInActivitySpace.getTranslation().getX(),
+                                        panelPoseInActivitySpace.getTranslation().getY(),
+                                        panelPoseInActivitySpace.getTranslation().getZ())
+                                .setOrientation(
+                                        panelShadowNode,
+                                        panelPoseInActivitySpace.getRotation().getX(),
+                                        panelPoseInActivitySpace.getRotation().getY(),
+                                        panelPoseInActivitySpace.getRotation().getZ(),
+                                        panelPoseInActivitySpace.getRotation().getW())
+                                .setParent(panelShadowNode, activitySpaceImpl.getNode())
+                                .apply();
+                    }
+                    surfacePackage.release();
+                });
+        isVisible = true;
+    }
+
+    private Pose getUpdatedPanelPoseInActivitySpace(
+            Pose openXrToProposedPanel, Pose openXrtoPlane) {
+        Pose planeToOpenXr = openXrtoPlane.getInverse();
+        Pose planeToPanel = planeToOpenXr.compose(openXrToProposedPanel);
+        Pose planeToProjectedPanel =
+                new Pose(
+                        new Vector3(
+                                planeToPanel.getTranslation().getX(),
+                                0f,
+                                planeToPanel.getTranslation().getZ()),
+                        planeToOpenXr
+                                .getRotation()
+                                .times(
+                                        PlaneUtils.rotateEntityToPlane(
+                                                openXrToProposedPanel.getRotation(),
+                                                openXrtoPlane.getRotation())));
+        Pose panelInOxr = openXrtoPlane.compose(planeToProjectedPanel);
+        return perceptionSpaceActivityPose.transformPoseTo(panelInOxr, activitySpaceImpl);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImpl.java
new file mode 100644
index 0000000..726f1f4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImpl.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.PerceptionSpaceActivityPose;
+import androidx.xr.scenecore.common.BaseActivityPose;
+
+/** A ActivityPose representing the origin of the OpenXR reference space. */
+final class PerceptionSpaceActivityPoseImpl extends BaseActivityPose
+        implements PerceptionSpaceActivityPose {
+
+    private final OpenXrActivityPoseHelper openXrActivityPoseHelper;
+
+    public PerceptionSpaceActivityPoseImpl(
+            ActivitySpaceImpl activitySpace, AndroidXrEntity activitySpaceRoot) {
+        this.openXrActivityPoseHelper =
+                new OpenXrActivityPoseHelper(activitySpace, activitySpaceRoot);
+    }
+
+    @Override
+    public Pose getPoseInActivitySpace() {
+        return openXrActivityPoseHelper.getPoseInActivitySpace(new Pose());
+    }
+
+    @Override
+    public Pose getActivitySpacePose() {
+        return openXrActivityPoseHelper.getActivitySpacePose(new Pose());
+    }
+
+    @Override
+    public Vector3 getActivitySpaceScale() {
+        // This ActivityPose is assumed to always have a scale of 1.0f in the OpenXR reference
+        // space.
+        return openXrActivityPoseHelper.getActivitySpaceScale(new Vector3(1f, 1f, 1f));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PlaneUtils.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PlaneUtils.java
new file mode 100644
index 0000000..386028d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PlaneUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+
+/** Utility functions for working with planes Poses. */
+final class PlaneUtils {
+    private PlaneUtils() {}
+
+    /**
+     * Gets the rotation relative to the plane to rotate the entity to be parallel to the plane.
+     *
+     * @param proposedRotation the initial rotation of the entity.
+     * @param planeRotation the rotation of the plane.
+     * @return the rotation of the panel rotated to be parallel to the plane relative to the plane.
+     */
+    static Quaternion rotateEntityToPlane(Quaternion proposedRotation, Quaternion planeRotation) {
+        // The y-vector of the plane is the normal of the plane. We need to rotate the panel so that
+        // the
+        // y-vector of the panel points along the plane and the z-vector is normal to the plane.
+        // Otherwise the panel will be sticking out of the plane.
+
+        // Create a rotation matrix from the quaternion of the plane to extract the normal.
+        Matrix4 planeMatrix = Matrix4.fromQuaternion(planeRotation);
+        // Create a rotation matrix from the quaternion for the proposed pose.
+        Matrix4 proposedRotationMatrix = Matrix4.fromQuaternion(proposedRotation);
+
+        // The z-vector of the panel should be the normal of the plane (which is the y-vector of the
+        // plane) so that the panel will be facing out of the plane.
+        float[] planeMatrixData = planeMatrix.getData();
+        Vector3 zRotation =
+                new Vector3(planeMatrixData[4], planeMatrixData[5], planeMatrixData[6])
+                        .toNormalized();
+        // Get the x-vector of the panel so that we can use it to create the y-vector that is in the
+        // direction of the panel.
+        float[] poseMatrixData = proposedRotationMatrix.getData();
+        Vector3 poseVectorX =
+                new Vector3(poseMatrixData[0], poseMatrixData[1], poseMatrixData[2]).toNormalized();
+        // The y-vector is the cross product of the panel x-vector and the z-vector.
+        Vector3 yRotation = zRotation.cross(poseVectorX).toNormalized();
+        // The x-vector is the cross product of the y-vector and the z-vector so that they will all
+        // be
+        // orthogonal.
+        Vector3 xRotation = yRotation.cross(zRotation).toNormalized();
+        // Create a new rotation matrix from the x, y, and z vectors.
+        Matrix4 rotationMatrix = getRotationMatrixFromAxes(xRotation, yRotation, zRotation);
+        return rotationMatrix.getRotation();
+    }
+
+    private static Matrix4 getRotationMatrixFromAxes(Vector3 xAxis, Vector3 yAxis, Vector3 zAxis) {
+        return new Matrix4(
+                new float[] {
+                    xAxis.getX(),
+                    xAxis.getY(),
+                    xAxis.getZ(),
+                    0f,
+                    yAxis.getX(),
+                    yAxis.getY(),
+                    yAxis.getZ(),
+                    0f,
+                    zAxis.getX(),
+                    zAxis.getY(),
+                    zAxis.getZ(),
+                    0f,
+                    0f,
+                    0f,
+                    0f,
+                    1f
+                });
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PointerCaptureComponentImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PointerCaptureComponentImpl.java
new file mode 100644
index 0000000..d16419b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/PointerCaptureComponentImpl.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import androidx.annotation.NonNull;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent.StateListener;
+
+import java.util.concurrent.Executor;
+
+/** Implementation of PointerCaptureComponent. */
+final class PointerCaptureComponentImpl implements PointerCaptureComponent {
+
+    private final Executor executor;
+    private final StateListener stateListener;
+    private final InputEventListener inputListener;
+
+    private AndroidXrEntity attachedEntity;
+
+    public PointerCaptureComponentImpl(
+            @NonNull Executor executor,
+            @NonNull StateListener stateListener,
+            @NonNull InputEventListener inputListener) {
+        this.executor = executor;
+        this.stateListener = stateListener;
+        this.inputListener = inputListener;
+    }
+
+    @Override
+    public boolean onAttach(Entity entity) {
+        if (!(entity instanceof AndroidXrEntity) || attachedEntity != null) {
+            return false;
+        }
+
+        attachedEntity = (AndroidXrEntity) entity;
+        return attachedEntity.requestPointerCapture(executor, inputListener, stateListener);
+    }
+
+    @Override
+    public void onDetach(Entity entity) {
+        attachedEntity.stopPointerCapture();
+        attachedEntity = null;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ResizableComponentImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ResizableComponentImpl.java
new file mode 100644
index 0000000..e7932e0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/ResizableComponentImpl.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.xr.extensions.Consumer;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEventListener;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+
+/** Implementation of ResizableComponent. */
+@SuppressWarnings({"BanConcurrentHashMap"})
+class ResizableComponentImpl implements ResizableComponent {
+
+    private static final String TAG = "ResizableComponentImpl";
+
+    private final XrExtensions extensions;
+    private final ExecutorService executor;
+    private final ConcurrentHashMap<ResizeEventListener, Executor> resizeEventListenerMap =
+            new ConcurrentHashMap<>();
+    // Visible for testing.
+    Consumer<ReformEvent> reformEventConsumer;
+    private Entity entity;
+    private Dimensions currentSize;
+    private Dimensions minSize;
+    private Dimensions maxSize;
+    private float fixedAspectRatio = 0.0f;
+
+    public ResizableComponentImpl(
+            ExecutorService executor,
+            XrExtensions extensions,
+            Dimensions minSize,
+            Dimensions maxSize) {
+        this.minSize = minSize;
+        this.maxSize = maxSize;
+        this.extensions = extensions;
+        this.executor = executor;
+    }
+
+    @Override
+    public boolean onAttach(Entity entity) {
+        if (this.entity != null) {
+            Log.e(TAG, "Already attached to entity " + this.entity);
+            return false;
+        }
+        this.entity = entity;
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setEnabledReform(
+                reformOptions.getEnabledReform() | ReformOptions.ALLOW_RESIZE);
+
+        // Update the current size if it's not set.
+        // TODO: b/348037292 - Remove this special case for PanelEntityImpl.
+        if (entity instanceof PanelEntityImpl && currentSize == null) {
+            currentSize = ((PanelEntityImpl) entity).getSize();
+            // TODO: b/350563642 - Add checks that size is within minsize and maxsize.
+        }
+        if (currentSize != null) {
+            reformOptions.setCurrentSize(
+                    new Vec3(currentSize.width, currentSize.height, currentSize.depth));
+        }
+        reformOptions.setMinimumSize(new Vec3(minSize.width, minSize.height, minSize.depth));
+        reformOptions.setMaximumSize(new Vec3(maxSize.width, maxSize.height, maxSize.depth));
+        reformOptions.setFixedAspectRatio(fixedAspectRatio);
+        ((AndroidXrEntity) entity).updateReformOptions();
+        if (reformEventConsumer != null) {
+            ((AndroidXrEntity) entity).addReformEventConsumer(reformEventConsumer, executor);
+        }
+        return true;
+    }
+
+    @Override
+    public void onDetach(Entity entity) {
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setEnabledReform(
+                reformOptions.getEnabledReform() & ~ReformOptions.ALLOW_RESIZE);
+        ((AndroidXrEntity) entity).updateReformOptions();
+        if (reformEventConsumer != null) {
+            ((AndroidXrEntity) entity).removeReformEventConsumer(reformEventConsumer);
+        }
+        this.entity = null;
+    }
+
+    @Override
+    public void setSize(Dimensions size) {
+        // TODO: b/350821054 - Implement synchronization policy around Entity/Component updates.
+        currentSize = size;
+        if (entity == null) {
+            Log.e(TAG, "This component isn't attached to an entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setCurrentSize(new Vec3(size.width, size.height, size.depth));
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    public void setMinimumSize(Dimensions minSize) {
+        this.minSize = minSize;
+        if (entity == null) {
+            Log.e(TAG, "This component isn't attached to an entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setMinimumSize(new Vec3(minSize.width, minSize.height, minSize.depth));
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    public void setMaximumSize(Dimensions maxSize) {
+        this.maxSize = maxSize;
+        if (entity == null) {
+            Log.e(TAG, "This component isn't attached to an entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setMaximumSize(new Vec3(maxSize.width, maxSize.height, maxSize.depth));
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    public void setFixedAspectRatio(float fixedAspectRatio) {
+        this.fixedAspectRatio = fixedAspectRatio;
+        if (entity == null) {
+            Log.e(TAG, "This component isn't attached to an entity.");
+            return;
+        }
+        ReformOptions reformOptions = ((AndroidXrEntity) entity).getReformOptions();
+        reformOptions.setFixedAspectRatio(fixedAspectRatio);
+        ((AndroidXrEntity) entity).updateReformOptions();
+    }
+
+    @Override
+    public void addResizeEventListener(
+            Executor resizeExecutor, ResizeEventListener resizeEventListener) {
+        resizeEventListenerMap.put(resizeEventListener, resizeExecutor);
+        if (reformEventConsumer != null) {
+            return;
+        }
+        reformEventConsumer =
+                reformEvent -> {
+                    if (reformEvent.getType() != ReformEvent.REFORM_TYPE_RESIZE) {
+                        return;
+                    }
+                    // Set the alpha to 0 when the resize starts and restore when resize ends, to
+                    // hide the
+                    // entity content while it's being resized.
+                    switch (reformEvent.getState()) {
+                        case ReformEvent.REFORM_STATE_START:
+                            try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+                                transaction
+                                        .setAlpha(((AndroidXrEntity) entity).getNode(), 0f)
+                                        .apply();
+                            }
+                            break;
+                        case ReformEvent.REFORM_STATE_END:
+                            entity.setAlpha(entity.getAlpha());
+                            break;
+                        default:
+                            break;
+                    }
+                    Dimensions newSize =
+                            new Dimensions(
+                                    reformEvent.getProposedSize().x,
+                                    reformEvent.getProposedSize().y,
+                                    reformEvent.getProposedSize().z);
+                    // Update the resize affordance size.
+                    setSize(newSize);
+                    resizeEventListenerMap.forEach(
+                            (listener, listenerExecutor) ->
+                                    listenerExecutor.execute(
+                                            () ->
+                                                    listener.onResizeEvent(
+                                                            new ResizeEvent(
+                                                                    RuntimeUtils
+                                                                            .getResizeEventState(
+                                                                                    reformEvent
+                                                                                            .getState()),
+                                                                    newSize))));
+                };
+        if (entity == null) {
+            Log.e(TAG, "This component isn't attached to an entity.");
+            return;
+        }
+        ((AndroidXrEntity) entity).addReformEventConsumer(reformEventConsumer, executor);
+    }
+
+    @Override
+    public void removeResizeEventListener(ResizeEventListener resizeEventListener) {
+        resizeEventListenerMap.remove(resizeEventListener);
+        if (resizeEventListenerMap.isEmpty()) {
+            ((AndroidXrEntity) entity).removeReformEventConsumer(reformEventConsumer);
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/RuntimeUtils.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/RuntimeUtils.java
new file mode 100644
index 0000000..09af0d1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/RuntimeUtils.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.InputEvent.Action;
+import androidx.xr.extensions.node.InputEvent.PointerType;
+import androidx.xr.extensions.node.InputEvent.Source;
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.extensions.node.Quatf;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose.Fov;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities;
+import androidx.xr.scenecore.impl.perception.Plane;
+
+final class RuntimeUtils {
+    private RuntimeUtils() {}
+
+    /** Convert JXRCore PlaneType to a PerceptionLibrary Plane.Type. */
+    static Plane.Type getPlaneType(PlaneType planeType) {
+        switch (planeType) {
+            case HORIZONTAL:
+                // TODO: b/329888869 - Allow Horizontal to work as both upward and downward facing.
+                // To do
+                // this it would have to return a collection.
+                return Plane.Type.HORIZONTAL_UPWARD_FACING;
+            case VERTICAL:
+                return Plane.Type.VERTICAL;
+            case ANY:
+                return Plane.Type.ARBITRARY;
+        }
+        return Plane.Type.ARBITRARY;
+    }
+
+    /** Convert a Perception Plane.Type to a JXRCore PlaneType. */
+    static PlaneType getPlaneType(Plane.Type planeType) {
+        switch (planeType) {
+            case HORIZONTAL_UPWARD_FACING:
+            case HORIZONTAL_DOWNWARD_FACING:
+                return PlaneType.HORIZONTAL;
+            case VERTICAL:
+                return PlaneType.VERTICAL;
+            default:
+                return PlaneType.ANY;
+        }
+    }
+
+    /** Convert a JXRCore PlaneSemantic to a PerceptionLibrary Plane.Label. */
+    static Plane.Label getPlaneLabel(PlaneSemantic planeSemantic) {
+        switch (planeSemantic) {
+            case WALL:
+                return Plane.Label.WALL;
+            case FLOOR:
+                return Plane.Label.FLOOR;
+            case CEILING:
+                return Plane.Label.CEILING;
+            case TABLE:
+                return Plane.Label.TABLE;
+            case ANY:
+                return Plane.Label.UNKNOWN;
+        }
+        return Plane.Label.UNKNOWN;
+    }
+
+    /** Convert a PerceptionLibrary Plane.Label to a JXRCore PlaneSemantic. */
+    static PlaneSemantic getPlaneSemantic(Plane.Label planeLabel) {
+        switch (planeLabel) {
+            case WALL:
+                return PlaneSemantic.WALL;
+            case FLOOR:
+                return PlaneSemantic.FLOOR;
+            case CEILING:
+                return PlaneSemantic.CEILING;
+            case TABLE:
+                return PlaneSemantic.TABLE;
+            default:
+                return PlaneSemantic.ANY;
+        }
+    }
+
+    @Nullable
+    private static HitInfo getHitInfo(
+            androidx.xr.extensions.node.InputEvent.HitInfo xrHitInfo, EntityManager entityManager) {
+        if (xrHitInfo == null
+                || xrHitInfo.getInputNode() == null
+                || xrHitInfo.getTransform() == null) {
+            return null;
+        }
+        // TODO: b/377541143 - Replace instance equality check in EntityManager.
+        Entity hitEntity = entityManager.getEntityForNode(xrHitInfo.getInputNode());
+        if (hitEntity == null) {
+            return null;
+        }
+        return new HitInfo(
+                entityManager.getEntityForNode(xrHitInfo.getInputNode()),
+                (xrHitInfo.getHitPosition() == null)
+                        ? null
+                        : getVector3(xrHitInfo.getHitPosition()),
+                getMatrix(xrHitInfo.getTransform()));
+    }
+
+    /**
+     * Converts an XR InputEvent to a JXRCore InputEvent.
+     *
+     * @param xrInputEvent an {@link androidx.xr.extensions.node.InputEvent} instance to be
+     *     converted.
+     * @param entityManager an {@link EntityManager} instance to look up entities.
+     * @return a {@link androidx.xr.scenecore.JXRCoreRuntime.InputEvent} instance representing the
+     *     input event.
+     */
+    static InputEvent getInputEvent(
+            @NonNull androidx.xr.extensions.node.InputEvent xrInputEvent,
+            @NonNull EntityManager entityManager) {
+        Vector3 origin = getVector3(xrInputEvent.getOrigin());
+        Vector3 direction = getVector3(xrInputEvent.getDirection());
+        HitInfo hitInfo = null;
+        HitInfo secondaryHitInfo = null;
+        if (xrInputEvent.getHitInfo() != null) {
+            hitInfo = getHitInfo(xrInputEvent.getHitInfo(), entityManager);
+        }
+        if (xrInputEvent.getSecondaryHitInfo() != null) {
+            secondaryHitInfo = getHitInfo(xrInputEvent.getSecondaryHitInfo(), entityManager);
+        }
+        return new InputEvent(
+                getInputEventSource(xrInputEvent.getSource()),
+                getInputEventPointerType(xrInputEvent.getPointerType()),
+                xrInputEvent.getTimestamp(),
+                origin,
+                direction,
+                getInputEventAction(xrInputEvent.getAction()),
+                hitInfo,
+                secondaryHitInfo);
+    }
+
+    @InputEvent.Source
+    static int getInputEventSource(@Source int xrInputEventSource) {
+        switch (xrInputEventSource) {
+            case androidx.xr.extensions.node.InputEvent.SOURCE_UNKNOWN:
+                return InputEvent.SOURCE_UNKNOWN;
+            case androidx.xr.extensions.node.InputEvent.SOURCE_HEAD:
+                return InputEvent.SOURCE_HEAD;
+            case androidx.xr.extensions.node.InputEvent.SOURCE_CONTROLLER:
+                return InputEvent.SOURCE_CONTROLLER;
+            case androidx.xr.extensions.node.InputEvent.SOURCE_HANDS:
+                return InputEvent.SOURCE_HANDS;
+            case androidx.xr.extensions.node.InputEvent.SOURCE_MOUSE:
+                return InputEvent.SOURCE_MOUSE;
+            case androidx.xr.extensions.node.InputEvent.SOURCE_GAZE_AND_GESTURE:
+                return InputEvent.SOURCE_GAZE_AND_GESTURE;
+            default:
+                throw new IllegalArgumentException(
+                        "Unknown Input Event Source: " + xrInputEventSource);
+        }
+    }
+
+    @InputEvent.PointerType
+    static int getInputEventPointerType(@PointerType int xrInputEventPointerType) {
+        switch (xrInputEventPointerType) {
+            case androidx.xr.extensions.node.InputEvent.POINTER_TYPE_DEFAULT:
+                return InputEvent.POINTER_TYPE_DEFAULT;
+            case androidx.xr.extensions.node.InputEvent.POINTER_TYPE_LEFT:
+                return InputEvent.POINTER_TYPE_LEFT;
+            case androidx.xr.extensions.node.InputEvent.POINTER_TYPE_RIGHT:
+                return InputEvent.POINTER_TYPE_RIGHT;
+            default:
+                throw new IllegalArgumentException(
+                        "Unknown Input Event Pointer Type: " + xrInputEventPointerType);
+        }
+    }
+
+    @InputEvent.Action
+    static int getInputEventAction(@Action int xrInputEventAction) {
+        switch (xrInputEventAction) {
+            case androidx.xr.extensions.node.InputEvent.ACTION_DOWN:
+                return InputEvent.ACTION_DOWN;
+            case androidx.xr.extensions.node.InputEvent.ACTION_UP:
+                return InputEvent.ACTION_UP;
+            case androidx.xr.extensions.node.InputEvent.ACTION_MOVE:
+                return InputEvent.ACTION_MOVE;
+            case androidx.xr.extensions.node.InputEvent.ACTION_CANCEL:
+                return InputEvent.ACTION_CANCEL;
+            case androidx.xr.extensions.node.InputEvent.ACTION_HOVER_MOVE:
+                return InputEvent.ACTION_HOVER_MOVE;
+            case androidx.xr.extensions.node.InputEvent.ACTION_HOVER_ENTER:
+                return InputEvent.ACTION_HOVER_ENTER;
+            case androidx.xr.extensions.node.InputEvent.ACTION_HOVER_EXIT:
+                return InputEvent.ACTION_HOVER_EXIT;
+            default:
+                throw new IllegalArgumentException(
+                        "Unknown Input Event Action: " + xrInputEventAction);
+        }
+    }
+
+    @ResizeEvent.ResizeState
+    static int getResizeEventState(@ReformEvent.ReformState int resizeState) {
+        switch (resizeState) {
+            case ReformEvent.REFORM_STATE_UNKNOWN:
+                return ResizeEvent.RESIZE_STATE_UNKNOWN;
+            case ReformEvent.REFORM_STATE_START:
+                return ResizeEvent.RESIZE_STATE_START;
+            case ReformEvent.REFORM_STATE_ONGOING:
+                return ResizeEvent.RESIZE_STATE_ONGOING;
+            case ReformEvent.REFORM_STATE_END:
+                return ResizeEvent.RESIZE_STATE_END;
+            default:
+                throw new IllegalArgumentException("Unknown Resize State: " + resizeState);
+        }
+    }
+
+    static Matrix4 getMatrix(Mat4f xrMatrix) {
+        float[] matrixData = new float[16];
+        for (int i = 0; i < 4; i++) {
+            System.arraycopy(xrMatrix.m[i], 0, matrixData, i * 4, 4);
+        }
+        return new Matrix4(matrixData);
+    }
+
+    static Pose getPose(Vec3 position, Quatf quatf) {
+        return new Pose(
+                new Vector3(position.x, position.y, position.z),
+                new Quaternion(quatf.x, quatf.y, quatf.z, quatf.w));
+    }
+
+    static Vector3 getVector3(Vec3 vec3) {
+        return new Vector3(vec3.x, vec3.y, vec3.z);
+    }
+
+    /**
+     * Converts from a perception pose type.
+     *
+     * @param perceptionPose a {@code androidx.xr.scenecore.impl.perception.Pose} instance
+     *     representing the pose.
+     */
+    static Pose fromPerceptionPose(androidx.xr.scenecore.impl.perception.Pose perceptionPose) {
+        Vector3 translation =
+                new Vector3(perceptionPose.tx(), perceptionPose.ty(), perceptionPose.tz());
+        Quaternion rotation =
+                new Quaternion(
+                        perceptionPose.qx(),
+                        perceptionPose.qy(),
+                        perceptionPose.qz(),
+                        perceptionPose.qw());
+        return new Pose(translation, rotation);
+    }
+
+    /**
+     * Converts from a pose to a perception pose type.
+     *
+     * @param pose a {@code androidx.xr.runtime.math.Pose} instance representing the pose.
+     */
+    static androidx.xr.scenecore.impl.perception.Pose poseToPerceptionPose(Pose pose) {
+        return new androidx.xr.scenecore.impl.perception.Pose(
+                pose.getTranslation().getX(),
+                pose.getTranslation().getY(),
+                pose.getTranslation().getZ(),
+                pose.getRotation().getX(),
+                pose.getRotation().getY(),
+                pose.getRotation().getZ(),
+                pose.getRotation().getW());
+    }
+
+    /**
+     * Converts to a JXRCore FOV from a perception FOV type.
+     *
+     * @param perceptionFov a {@code androidx.xr.scenecore.impl.perception.Fov} instance
+     *     representing the FOV.
+     */
+    static Fov fovFromPerceptionFov(androidx.xr.scenecore.impl.perception.Fov perceptionFov) {
+        return new Fov(
+                perceptionFov.getAngleLeft(),
+                perceptionFov.getAngleRight(),
+                perceptionFov.getAngleUp(),
+                perceptionFov.getAngleDown());
+    }
+
+    /**
+     * Converts to a perception FOV from a JXRCore FOV type.
+     *
+     * @param fov a {@code androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose.Fov}
+     *     instance representing the FOV.
+     */
+    static androidx.xr.scenecore.impl.perception.Fov perceptionFovFromFov(Fov fov) {
+        return new androidx.xr.scenecore.impl.perception.Fov(
+                fov.angleLeft, fov.angleRight, fov.angleUp, fov.angleDown);
+    }
+
+    /**
+     * Converts from the Extensions spatial capabilities to the runtime spatial capabilities.
+     *
+     * @param extCapabilities a {@link androidx.xr.extensions.space.SpatialCapabilities} instance to
+     *     be converted.
+     */
+    static SpatialCapabilities convertSpatialCapabilities(
+            androidx.xr.extensions.space.SpatialCapabilities extCapabilities) {
+        @SpatialCapabilities.SpatialCapability int capabilities = 0;
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_UI_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_UI;
+        }
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_3D_CONTENTS_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT;
+        }
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities.PASSTHROUGH_CONTROL_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL;
+        }
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities.APP_ENVIRONMENTS_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT;
+        }
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_AUDIO_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO;
+        }
+        if (extCapabilities.get(
+                androidx.xr.extensions.space.SpatialCapabilities
+                        .SPATIAL_ACTIVITY_EMBEDDING_CAPABLE)) {
+            capabilities |= SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY;
+        }
+
+        return new SpatialCapabilities(capabilities);
+    }
+
+    /**
+     * Converts from the Extensions environment visibility state to the runtime environment
+     * visibility state.
+     *
+     * @param environmentState a {@link
+     *     androidx.xr.extensions.environment.EnvironmentVisibilityState} instance to be converted.
+     */
+    static boolean getIsSpatialEnvironmentPreferenceActive(
+            @EnvironmentVisibilityState.State int environmentState) {
+        return environmentState == EnvironmentVisibilityState.APP_VISIBLE;
+    }
+
+    static float getPassthroughOpacity(PassthroughVisibilityState passthroughVisibilityState) {
+        int passthroughState = passthroughVisibilityState.getCurrentState();
+        if (passthroughState == PassthroughVisibilityState.DISABLED) {
+            return 0.0f;
+        } else {
+            float opacity = passthroughVisibilityState.getOpacity();
+            if (opacity > 0.0f) {
+                return opacity;
+            } else {
+                // When passthrough is enabled, the opacity should be greater than zero.
+                Log.e(
+                        "RuntimeUtils",
+                        "Passthrough is enabled, but active opacity value is "
+                                + opacity
+                                + ". Opacity should be greater than zero when Passthrough is"
+                                + " enabled.");
+                return 1.0f;
+            }
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImpl.java
new file mode 100644
index 0000000..1bd0c5e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImpl.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.media.SoundPool;
+
+import androidx.xr.extensions.media.SoundPoolExtensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointSourceAttributes;
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundFieldAttributes;
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundPoolExtensionsWrapper;
+
+/** Implementation of {@link SoundPoolExtensionsWrapper}. */
+final class SoundPoolExtensionsWrapperImpl implements SoundPoolExtensionsWrapper {
+
+    private final SoundPoolExtensions extensions;
+
+    SoundPoolExtensionsWrapperImpl(SoundPoolExtensions extensions) {
+        this.extensions = extensions;
+    }
+
+    @Override
+    public int play(
+            SoundPool soundPool,
+            int soundId,
+            PointSourceAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        androidx.xr.extensions.media.PointSourceAttributes extAttributes =
+                MediaUtils.convertPointSourceAttributesToExtensions(attributes);
+        return extensions.playAsPointSource(
+                soundPool, soundId, extAttributes, volume, priority, loop, rate);
+    }
+
+    @Override
+    public int play(
+            SoundPool soundPool,
+            int soundId,
+            SoundFieldAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        androidx.xr.extensions.media.SoundFieldAttributes extAttributes =
+                MediaUtils.convertSoundFieldAttributesToExtensions(attributes);
+
+        return extensions.playAsSoundField(
+                soundPool, soundId, extAttributes, volume, priority, loop, rate);
+    }
+
+    @Override
+    public int getSpatialSourceType(SoundPool soundPool, int streamId) {
+        return MediaUtils.convertExtensionsToSourceType(
+                extensions.getSpatialSourceType(soundPool, streamId));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SpatialEnvironmentImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SpatialEnvironmentImpl.java
new file mode 100644
index 0000000..089f446
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SpatialEnvironmentImpl.java
@@ -0,0 +1,574 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.app.Activity;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.xr.extensions.XrExtensionResult;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.extensions.passthrough.PassthroughState;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.androidxr.splitengine.SubspaceNode;
+import com.google.ar.imp.apibindings.ImpressApi;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/** Concrete implementation of SpatialEnvironment / XR Wallpaper for Android XR. */
+// TODO(b/373435470): Remove "deprecation"
+@SuppressWarnings({"deprecation", "BanSynchronizedMethods"})
+final class SpatialEnvironmentImpl implements SpatialEnvironment {
+
+    public static final String TAG = "SpatialEnvironmentImpl";
+
+    public static final String SKYBOX_NODE_NAME = "EnvironmentSkyboxNode";
+    public static final String GEOMETRY_NODE_NAME = "EnvironmentGeometryNode";
+    public static final String PASSTHROUGH_NODE_NAME = "EnvironmentPassthroughNode";
+    @VisibleForTesting final Node passthroughNode;
+    private final XrExtensions xrExtensions;
+    private final Node rootEnvironmentNode;
+    private final boolean useSplitEngine;
+    @Nullable private Activity activity;
+    // Used to represent the geometry
+    private Node geometryNode;
+    // the "xrExtensions.setEnvironment" call effectively makes a node into a skybox
+    private Node skyboxNode;
+    private SubspaceNode geometrySubspaceSplitEngine;
+    private int geometrySubspaceImpressNode;
+    private boolean isSpatialEnvironmentPreferenceActive = false;
+    @Nullable private SpatialEnvironmentPreference spatialEnvironmentPreference = null;
+
+    // The active passthrough opacity value is updated with every opacity change event. A null value
+    // indicates it has not yet been initialized and the value should be read from the
+    // spatialStateProvider.
+    private Float activePassthroughOpacity = null;
+    // Initialized to null to let system control opacity until preference is explicitly set.
+    private Float passthroughOpacityPreference = null;
+    private SplitEngineSubspaceManager splitEngineSubspaceManager;
+    private ImpressApi impressApi;
+    private final Supplier<SpatialState> spatialStateProvider;
+    private SpatialState previousSpatialState = null;
+
+    private final Set<Consumer<Boolean>> onSpatialEnvironmentChangedListeners =
+            Collections.synchronizedSet(new HashSet<>());
+
+    private final Set<Consumer<Float>> onPassthroughOpacityChangedListeners =
+            Collections.synchronizedSet(new HashSet<>());
+
+    public SpatialEnvironmentImpl(
+            @NonNull Activity activity,
+            @NonNull XrExtensions xrExtensions,
+            @NonNull Node rootSceneNode,
+            @NonNull Supplier<SpatialState> spatialStateProvider,
+            boolean useSplitEngine) {
+        this.activity = activity;
+        this.xrExtensions = xrExtensions;
+        this.passthroughNode = xrExtensions.createNode();
+        this.rootEnvironmentNode = xrExtensions.createNode();
+        this.geometryNode = xrExtensions.createNode();
+        this.skyboxNode = xrExtensions.createNode();
+        this.useSplitEngine = useSplitEngine;
+        this.spatialStateProvider = spatialStateProvider;
+
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction
+                    .setName(geometryNode, GEOMETRY_NODE_NAME)
+                    .setName(skyboxNode, SKYBOX_NODE_NAME)
+                    .setName(passthroughNode, PASSTHROUGH_NODE_NAME)
+                    .setParent(geometryNode, rootEnvironmentNode)
+                    .setParent(skyboxNode, rootEnvironmentNode)
+                    .setParent(passthroughNode, rootSceneNode)
+                    .apply();
+        }
+    }
+
+    // TODO: Remove these once we know the Equals() and Hashcode() methods are correct.
+    boolean hasEnvironmentVisibilityChanged(@NonNull SpatialState spatialState) {
+        if (previousSpatialState == null) {
+            return true;
+        }
+
+        final EnvironmentVisibilityState previousEnvironmentVisibility =
+                previousSpatialState.getEnvironmentVisibility();
+        final EnvironmentVisibilityState currentEnvironmentVisibility =
+                spatialState.getEnvironmentVisibility();
+
+        if (previousEnvironmentVisibility.getCurrentState()
+                != currentEnvironmentVisibility.getCurrentState()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    // TODO: Remove these once we know the Equals() and Hashcode() methods are correct.
+    boolean hasPassthroughVisibilityChanged(@NonNull SpatialState spatialState) {
+        if (previousSpatialState == null) {
+            return true;
+        }
+
+        final PassthroughVisibilityState previousPassthroughVisibility =
+                previousSpatialState.getPassthroughVisibility();
+        final PassthroughVisibilityState currentPassthroughVisibility =
+                spatialState.getPassthroughVisibility();
+
+        if (previousPassthroughVisibility.getCurrentState()
+                != currentPassthroughVisibility.getCurrentState()) {
+            return true;
+        }
+
+        if (previousPassthroughVisibility.getOpacity()
+                != currentPassthroughVisibility.getOpacity()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    // Package Private enum to return which spatial states have changed.
+    enum ChangedSpatialStates {
+        ENVIRONMENT_CHANGED,
+        PASSTHROUGH_CHANGED
+    }
+
+    // Package Private method to set the current passthrough opacity and
+    // isSpatialEnvironmentPreferenceActive from JxrPlatformAdapterAxr.
+    // This method is synchronized because it sets several internal state variables at once, which
+    // should be treated as an atomic set. We could consider replacing with AtomicReferences.
+    @CanIgnoreReturnValue
+    synchronized EnumSet<ChangedSpatialStates> setSpatialState(@NonNull SpatialState spatialState) {
+        EnumSet<ChangedSpatialStates> changedSpatialStates =
+                EnumSet.noneOf(ChangedSpatialStates.class);
+        boolean passthroughVisibilityChanged = hasPassthroughVisibilityChanged(spatialState);
+        if (passthroughVisibilityChanged) {
+            changedSpatialStates.add(ChangedSpatialStates.PASSTHROUGH_CHANGED);
+            this.activePassthroughOpacity =
+                    RuntimeUtils.getPassthroughOpacity(spatialState.getPassthroughVisibility());
+        }
+
+        // TODO: b/371082454 - Check if the app is in FSM to ensure APP_VISIBLE refers to the
+        // current
+        // app and not another app that is visible.
+        boolean environmentVisibilityChanged = hasEnvironmentVisibilityChanged(spatialState);
+        if (environmentVisibilityChanged) {
+            changedSpatialStates.add(ChangedSpatialStates.ENVIRONMENT_CHANGED);
+            this.isSpatialEnvironmentPreferenceActive =
+                    RuntimeUtils.getIsSpatialEnvironmentPreferenceActive(
+                            spatialState.getEnvironmentVisibility().getCurrentState());
+        }
+
+        this.previousSpatialState = spatialState;
+        return changedSpatialStates;
+    }
+
+    /** Flushes passthrough Node state to XrExtensions. */
+    private void applyPassthroughChange(float opacityVal) {
+        if (opacityVal > 0.0f) {
+            try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+                transaction
+                        .setPassthroughState(
+                                passthroughNode, opacityVal, PassthroughState.PASSTHROUGH_MODE_MAX)
+                        .apply();
+            }
+        } else {
+            try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+                transaction
+                        .setPassthroughState(
+                                passthroughNode,
+                                /* passthroughOpacity= */ 0.0f,
+                                PassthroughState.PASSTHROUGH_MODE_OFF)
+                        .apply();
+            }
+        }
+    }
+
+    @Override
+    @NonNull
+    @CanIgnoreReturnValue
+    public SetPassthroughOpacityPreferenceResult setPassthroughOpacityPreference(
+            @Nullable Float opacity) {
+        // To work around floating-point precision issues, the opacity preference is documented to
+        // clamp
+        // to 0.0f if it is set below 1% opacity and it clamps to 1.0f if it is set above 99%
+        // opacity.
+
+        @Nullable
+        Float newPassthroughOpacityPreference =
+                opacity == null
+                        ? null
+                        : (opacity < 0.01f ? 0.0f : (opacity > 0.99f ? 1.0f : opacity));
+
+        if (Objects.equals(newPassthroughOpacityPreference, passthroughOpacityPreference)) {
+            return SetPassthroughOpacityPreferenceResult.CHANGE_APPLIED;
+        }
+
+        passthroughOpacityPreference = newPassthroughOpacityPreference;
+
+        // to this method when they are removed
+
+        // Passthrough should be enabled only if the user has explicitly set the
+        // PassthroughOpacityPreference to a non-null and non-zero value, otherwise disabled.
+        if (passthroughOpacityPreference != null && passthroughOpacityPreference != 0.0f) {
+            applyPassthroughChange(passthroughOpacityPreference.floatValue());
+        } else {
+            applyPassthroughChange(0.0f);
+        }
+
+        if (RuntimeUtils.convertSpatialCapabilities(
+                        spatialStateProvider.get().getSpatialCapabilities())
+                .hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL)) {
+            return SetPassthroughOpacityPreferenceResult.CHANGE_APPLIED;
+        } else {
+            return SetPassthroughOpacityPreferenceResult.CHANGE_PENDING;
+        }
+    }
+
+    // Synchronized because we may need to update the entire Spatial State if the opacity has not
+    // been
+    // initialized previously.
+    @Override
+    public synchronized float getCurrentPassthroughOpacity() {
+        if (activePassthroughOpacity == null) {
+            setSpatialState(spatialStateProvider.get());
+        }
+        return activePassthroughOpacity.floatValue();
+    }
+
+    @Override
+    @Nullable
+    public Float getPassthroughOpacityPreference() {
+        return passthroughOpacityPreference;
+    }
+
+    // This is called on the Activity's UI thread - so we should be careful to not block it.
+    synchronized void firePassthroughOpacityChangedEvent(float opacity) {
+        for (Consumer<Float> listener : onPassthroughOpacityChangedListeners) {
+            listener.accept(opacity);
+        }
+    }
+
+    @Override
+    public void addOnPassthroughOpacityChangedListener(Consumer<Float> listener) {
+        onPassthroughOpacityChangedListeners.add(listener);
+    }
+
+    @Override
+    public void removeOnPassthroughOpacityChangedListener(Consumer<Float> listener) {
+        onPassthroughOpacityChangedListeners.remove(listener);
+    }
+
+    /**
+     * Stages updates to the CPM graph for the Environment to reflect a new skybox preference. If
+     * skybox is null, this method unsets the client skybox preference, resulting in the system
+     * skybox being used.
+     */
+    private void applySkybox(@Nullable ExrImageResourceImpl skybox) {
+        // We need to create a new node here because we can't re-use the old CPM node when changing
+        // geometry and skybox.
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction.setParent(skyboxNode, null).apply();
+        }
+
+        this.skyboxNode = xrExtensions.createNode();
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction
+                    .setName(skyboxNode, SKYBOX_NODE_NAME)
+                    .setParent(skyboxNode, rootEnvironmentNode);
+            if (skybox != null) {
+                transaction.setEnvironment(skyboxNode, skybox.getToken());
+            }
+            transaction.apply();
+        }
+    }
+
+    /**
+     * Stages updates to the CPM graph for the Environment to reflect a new geometry preference. If
+     * geometry is null, this method unsets the client geometry preference, resulting in the system
+     * geometry being used.
+     */
+    private void applyGeometryLegacy(@Nullable GltfModelResourceImpl geometry) {
+        // We need to create a new node here because we can't re-use the old CPM node when changing
+        // geometry and skybox.
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction.setParent(geometryNode, null).apply();
+        }
+        this.geometryNode = xrExtensions.createNode();
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction
+                    .setName(geometryNode, GEOMETRY_NODE_NAME)
+                    .setParent(geometryNode, rootEnvironmentNode);
+            if (geometry != null) {
+                transaction.setGltfModel(geometryNode, geometry.getExtensionModelToken());
+            }
+            transaction.apply();
+        }
+    }
+
+    /**
+     * Stages updates to the CPM graph for the Environment to reflect a new geometry preference. If
+     * geometry is null, this method unsets the client geometry preference, resulting in the system
+     * geometry being used.
+     *
+     * @throws IllegalStateException if called on a thread other than the main thread.
+     */
+    private void applyGeometrySplitEngine(@Nullable GltfModelResourceImplSplitEngine geometry) {
+        if (!Looper.getMainLooper().isCurrentThread()) {
+            throw new IllegalStateException("This method must be called on the main thread.");
+        }
+
+        int prevGeometrySubspaceImpressNode = -1;
+        SubspaceNode prevGeometrySubspaceSplitEngine = null;
+        if (geometrySubspaceSplitEngine != null) {
+            prevGeometrySubspaceSplitEngine = geometrySubspaceSplitEngine;
+            geometrySubspaceSplitEngine = null;
+            prevGeometrySubspaceImpressNode = geometrySubspaceImpressNode;
+            geometrySubspaceImpressNode = -1;
+        }
+
+        geometrySubspaceImpressNode = impressApi.createImpressNode();
+        String subspaceName = "geometry_subspace_" + geometrySubspaceImpressNode;
+
+        geometrySubspaceSplitEngine =
+                splitEngineSubspaceManager.createSubspace(
+                        subspaceName, geometrySubspaceImpressNode);
+
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction
+                    .setName(geometrySubspaceSplitEngine.subspaceNode, GEOMETRY_NODE_NAME)
+                    .setParent(geometrySubspaceSplitEngine.subspaceNode, rootEnvironmentNode)
+                    .setPosition(geometrySubspaceSplitEngine.subspaceNode, 0.0f, 0.0f, 0.0f)
+                    .setScale(geometrySubspaceSplitEngine.subspaceNode, 1.0f, 1.0f, 1.0f)
+                    .setOrientation(
+                            geometrySubspaceSplitEngine.subspaceNode, 0.0f, 0.0f, 0.0f, 1.0f)
+                    .apply();
+        }
+
+        if (geometry != null) {
+            int modelImpressNode =
+                    impressApi.instanceGltfModel(
+                            geometry.getExtensionModelToken(), /* enableCollider= */ false);
+            impressApi.setImpressNodeParent(modelImpressNode, geometrySubspaceImpressNode);
+        }
+
+        if (prevGeometrySubspaceSplitEngine != null && prevGeometrySubspaceImpressNode != -1) {
+            // Detach the previous geometry subspace from the root environment node.
+            try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+                transaction.setParent(prevGeometrySubspaceSplitEngine.subspaceNode, null).apply();
+            }
+            splitEngineSubspaceManager.deleteSubspace(prevGeometrySubspaceSplitEngine.subspaceId);
+            impressApi.destroyImpressNode(prevGeometrySubspaceImpressNode);
+
+            prevGeometrySubspaceSplitEngine = null;
+            prevGeometrySubspaceImpressNode = -1;
+        }
+    }
+
+    void onSplitEngineReady(SplitEngineSubspaceManager subspaceManager, ImpressApi api) {
+        this.splitEngineSubspaceManager = subspaceManager;
+        this.impressApi = api;
+    }
+
+    @Override
+    @NonNull
+    @CanIgnoreReturnValue
+    public SetSpatialEnvironmentPreferenceResult setSpatialEnvironmentPreference(
+            @Nullable SpatialEnvironmentPreference newPreference) {
+        // TODO: b/378914007 This method is not safe for reentrant calls.
+
+        if (Objects.equals(newPreference, spatialEnvironmentPreference)) {
+            return SetSpatialEnvironmentPreferenceResult.CHANGE_APPLIED;
+        }
+
+        GltfModelResource newGeometry = newPreference == null ? null : newPreference.geometry;
+        GltfModelResource prevGeometry =
+                spatialEnvironmentPreference == null ? null : spatialEnvironmentPreference.geometry;
+        ExrImageResource newSkybox = newPreference == null ? null : newPreference.skybox;
+        ExrImageResource prevSkybox =
+                spatialEnvironmentPreference == null ? null : spatialEnvironmentPreference.skybox;
+
+        // TODO(b/329907079): Map GltfModelResourceImplSplitEngine to GltfModelResource in Impl
+        // Layer
+        if (newGeometry != null) {
+            if (useSplitEngine && !(newGeometry instanceof GltfModelResourceImplSplitEngine)) {
+                throw new IllegalArgumentException(
+                        "SplitEngine is enabled but the prefererred geometry is not of type"
+                                + " GltfModelResourceImplSplitEngine.");
+            } else if (!useSplitEngine && !(newGeometry instanceof GltfModelResourceImpl)) {
+                throw new IllegalArgumentException(
+                        "SplitEngine is disabled but the prefererred geometry is not of type"
+                                + " GltfModelResourceImpl.");
+            }
+        }
+
+        // TODO b/329907079: Map ExrImageResourceImpl to ExrImageResource in Impl Layer
+        if (newSkybox != null && !(newSkybox instanceof ExrImageResourceImpl)) {
+            throw new IllegalArgumentException(
+                    "The prefererred skybox is not of type ExrImageResourceImpl.");
+        }
+
+        if (!Objects.equals(newGeometry, prevGeometry)) {
+            // TODO: b/354711945 - Remove this check once we migrate completely to SplitEngine
+            if (useSplitEngine) {
+                applyGeometrySplitEngine((GltfModelResourceImplSplitEngine) newGeometry);
+            } else {
+                applyGeometryLegacy((GltfModelResourceImpl) newGeometry);
+            }
+        }
+
+        if (!Objects.equals(newSkybox, prevSkybox)) {
+            // TODO: b/371221872 - If the preference object is non-null but contains a null skybox,
+            // we
+            // should set a black skybox. (This may require a change to the attach/detach logic
+            // below.)
+            applySkybox((ExrImageResourceImpl) newSkybox);
+        }
+
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            if (newSkybox == null && newGeometry == null) {
+                xrExtensions.detachSpatialEnvironment(
+                        activity,
+                        (result) -> logXrExtensionResult("detachSpatialEnvironment", result),
+                        Runnable::run);
+            } else {
+                xrExtensions.attachSpatialEnvironment(
+                        activity,
+                        rootEnvironmentNode,
+                        (result) -> logXrExtensionResult("attachSpatialEnvironment", result),
+                        Runnable::run);
+            }
+        }
+
+        this.spatialEnvironmentPreference = newPreference;
+
+        if (RuntimeUtils.convertSpatialCapabilities(
+                        spatialStateProvider.get().getSpatialCapabilities())
+                .hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT)) {
+            return SetSpatialEnvironmentPreferenceResult.CHANGE_APPLIED;
+        } else {
+            return SetSpatialEnvironmentPreferenceResult.CHANGE_PENDING;
+        }
+    }
+
+    private void logXrExtensionResult(String prefix, XrExtensionResult result) {
+        // TODO: b/376934871 - Better error handling?
+        switch (result.getResult()) {
+            case XrExtensionResult.XR_RESULT_SUCCESS:
+            case XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE:
+                Log.d(TAG, prefix + ": success (" + result.getResult() + ")");
+                break;
+            case XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED:
+                Log.d(TAG, prefix + ": ignored, already applied (" + result.getResult() + ")");
+                break;
+            case XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED:
+            case XrExtensionResult.XR_RESULT_ERROR_SYSTEM:
+                Log.e(TAG, prefix + ": error (" + result.getResult() + ")");
+                break;
+            default:
+                Log.e(TAG, prefix + ": Unexpected return value (" + result.getResult() + ")");
+                break;
+        }
+    }
+
+    @Override
+    @Nullable
+    public SpatialEnvironmentPreference getSpatialEnvironmentPreference() {
+        return spatialEnvironmentPreference;
+    }
+
+    @Override
+    public boolean isSpatialEnvironmentPreferenceActive() {
+        return isSpatialEnvironmentPreferenceActive;
+    }
+
+    // This is called on the Activity's UI thread - so we should be careful to not block it.
+    synchronized void fireOnSpatialEnvironmentChangedEvent(
+            boolean isSpatialEnvironmentPreferenceActive) {
+        for (Consumer<Boolean> listener : onSpatialEnvironmentChangedListeners) {
+            listener.accept(isSpatialEnvironmentPreferenceActive);
+        }
+    }
+
+    @Override
+    public void addOnSpatialEnvironmentChangedListener(Consumer<Boolean> listener) {
+        onSpatialEnvironmentChangedListeners.add(listener);
+    }
+
+    @Override
+    public void removeOnSpatialEnvironmentChangedListener(Consumer<Boolean> listener) {
+        onSpatialEnvironmentChangedListeners.remove(listener);
+    }
+
+    /**
+     * Disposes of the environment and all of its resources.
+     *
+     * <p>This should be called when the environment is no longer needed.
+     */
+    public void dispose() {
+        if (useSplitEngine) {
+            if (this.geometrySubspaceSplitEngine != null) {
+                try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+                    transaction.setParent(geometrySubspaceSplitEngine.subspaceNode, null).apply();
+                }
+                this.splitEngineSubspaceManager.deleteSubspace(
+                        this.geometrySubspaceSplitEngine.subspaceId);
+                this.geometrySubspaceSplitEngine = null;
+                impressApi.destroyImpressNode(geometrySubspaceImpressNode);
+            }
+        }
+        this.activePassthroughOpacity = null;
+        this.passthroughOpacityPreference = null;
+        try (NodeTransaction transaction = xrExtensions.createNodeTransaction()) {
+            transaction
+                    .setParent(skyboxNode, null)
+                    .setParent(geometryNode, null)
+                    .setParent(passthroughNode, null)
+                    .apply();
+        }
+        this.geometrySubspaceSplitEngine = null;
+        this.geometrySubspaceImpressNode = 0;
+        this.splitEngineSubspaceManager = null;
+        this.impressApi = null;
+        this.spatialEnvironmentPreference = null;
+        this.isSpatialEnvironmentPreferenceActive = false;
+        this.onPassthroughOpacityChangedListeners.clear();
+        this.onSpatialEnvironmentChangedListeners.clear();
+        // TODO: b/376934871 - Check async results.
+        xrExtensions.detachSpatialEnvironment(activity, (result) -> {}, Runnable::run);
+        this.activity = null;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntityImpl.java
new file mode 100644
index 0000000..27584f0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntityImpl.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.view.Surface;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of a RealityCore StereoSurfaceEntityImpl.
+ *
+ * <p>Unimplemented, this requires split engine.
+ */
+final class StereoSurfaceEntityImpl extends AndroidXrEntity implements StereoSurfaceEntity {
+    @StereoMode private int stereoMode;
+
+    public StereoSurfaceEntityImpl(
+            Entity parentEntity,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(extensions.createNode(), extensions, entityManager, executor);
+        throw new UnsupportedOperationException();
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        super.dispose();
+    }
+
+    @Override
+    public void setStereoMode(@StereoMode int mode) {
+        stereoMode = mode;
+    }
+
+    @Override
+    public int getStereoMode() {
+        return stereoMode;
+    }
+
+    @Override
+    public void setDimensions(Dimensions dimensions) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Dimensions getDimensions() {
+        return new Dimensions(0.0f, 0.0f, 0.0f);
+    }
+
+    @Override
+    public Surface getSurface() {
+        throw new UnsupportedOperationException();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntitySplitEngineImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntitySplitEngineImpl.java
new file mode 100644
index 0000000..60286b6
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/StereoSurfaceEntitySplitEngineImpl.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.view.Surface;
+
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.NodeTransaction;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.androidxr.splitengine.SubspaceNode;
+import com.google.ar.imp.apibindings.ImpressApi;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Implementation of a RealityCore StereoSurfaceEntitySplitEngine.
+ *
+ * <p>This is used to create an entity that contains a StereoSurfacePanel using the Split Engine
+ * route.
+ */
+final class StereoSurfaceEntitySplitEngineImpl extends AndroidXrEntity
+        implements StereoSurfaceEntity {
+    private final ImpressApi impressApi;
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager;
+    private final SubspaceNode subspace;
+    // TODO: b/362520810 - Wrap impress nodes w/ Java class.
+    private final int panelImpressNode;
+    private final int subspaceImpressNode;
+    @StereoMode private int stereoMode = StereoSurfaceEntity.StereoMode.SIDE_BY_SIDE;
+    // This are the default dimensions Impress starts with for the Quad mesh used as the canvas
+    private Dimensions dimensions = new Dimensions(2.0f, 2.0f, 2.0f);
+
+    public StereoSurfaceEntitySplitEngineImpl(
+            Entity parentEntity,
+            ImpressApi impressApi,
+            SplitEngineSubspaceManager splitEngineSubspaceManager,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor,
+            @StereoMode int stereoMode) {
+        super(extensions.createNode(), extensions, entityManager, executor);
+        this.impressApi = impressApi;
+        this.splitEngineSubspaceManager = splitEngineSubspaceManager;
+        this.stereoMode = stereoMode;
+        setParent(parentEntity);
+
+        // TODO(b/377906324): - Punt this logic to the UI thread, so that applications can create
+        // StereoSurface entities from any thread.
+
+        // System will only render Impress nodes that are parented by this subspace node.
+        this.subspaceImpressNode = impressApi.createImpressNode();
+        String subspaceName = "stereo_surface_panel_entity_subspace_" + subspaceImpressNode;
+
+        this.subspace =
+                splitEngineSubspaceManager.createSubspace(subspaceName, subspaceImpressNode);
+
+        try (NodeTransaction transaction = extensions.createNodeTransaction()) {
+            // Make the Entity node a parent of the subspace node.
+            transaction.setParent(subspace.subspaceNode, this.node).apply();
+        }
+        // The CPM node hierarchy is: Entity CPM node --- parent of ---> Subspace CPM node.
+        this.panelImpressNode = impressApi.createStereoSurface(stereoMode);
+        impressApi.setImpressNodeParent(panelImpressNode, subspaceImpressNode);
+    }
+
+    @SuppressWarnings("ObjectToString")
+    @Override
+    public void dispose() {
+        // TODO(b/377906324): - Punt this logic to the UI thread, so that applications can destroy
+        // StereoSurface entities from any thread.
+        splitEngineSubspaceManager.deleteSubspace(subspace.subspaceId);
+        impressApi.destroyImpressNode(subspaceImpressNode);
+        super.dispose();
+    }
+
+    @Override
+    public void setStereoMode(@StereoMode int mode) {
+        stereoMode = mode;
+        impressApi.setStereoModeForStereoSurface(panelImpressNode, mode);
+    }
+
+    @Override
+    public void setDimensions(Dimensions dimensions) {
+        // TODO(b/377906324): - Punt this logic to the UI thread, so that applications can call this
+        // method from any thread.
+        this.dimensions = dimensions;
+        impressApi.setCanvasDimensionsForStereoSurface(
+                panelImpressNode, dimensions.width, dimensions.height);
+    }
+
+    @Override
+    public Dimensions getDimensions() {
+        return dimensions;
+    }
+
+    @Override
+    @StereoMode
+    public int getStereoMode() {
+        return stereoMode;
+    }
+
+    @Override
+    public Surface getSurface() {
+        // TODO(b/377906324) - Either cache the surface in the constructor, or change this interface
+        // to
+        // return a Future.
+        return impressApi.getSurfaceFromStereoSurface(panelImpressNode);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SystemSpaceEntityImpl.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SystemSpaceEntityImpl.java
new file mode 100644
index 0000000..7d6fe4d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/SystemSpaceEntityImpl.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.xr.extensions.XrExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+
+import java.io.Closeable;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * A parentless system-controlled JXRCore Entity that defines its own coordinate space.
+ *
+ * <p>It is expected to be the soft root of its own parent-child entity hierarchy.
+ */
+abstract class SystemSpaceEntityImpl extends AndroidXrEntity
+        implements JxrPlatformAdapter.SystemSpaceEntity {
+
+    protected Pose openXrReferenceSpacePose;
+    protected Vector3 worldSpaceScale = new Vector3(1f, 1f, 1f);
+    // Visible for testing.
+    Closeable nodeTransformCloseable;
+    private OnSpaceUpdatedListener spaceUpdatedListener;
+    private Executor spaceUpdatedExecutor;
+
+    public SystemSpaceEntityImpl(
+            Node node,
+            XrExtensions extensions,
+            EntityManager entityManager,
+            ScheduledExecutorService executor) {
+        super(node, extensions, entityManager, executor);
+
+        // The underlying CPM node is always expected to be updated in response to changes to
+        // the coordinate space represented by a SystemSpaceEntityImpl so we subscribe at
+        // construction.
+        subscribeToNodeTransform(node, executor);
+    }
+
+    /** Called when the underlying space has changed. */
+    public void onSpaceUpdated() {
+        if (spaceUpdatedListener != null) {
+            this.spaceUpdatedExecutor.execute(() -> spaceUpdatedListener.onSpaceUpdated());
+        }
+    }
+
+    /** Registers the SDK layer / application's listener for space updates. */
+    @Override
+    public void setOnSpaceUpdatedListener(
+            @Nullable OnSpaceUpdatedListener listener, @Nullable Executor executor) {
+        this.spaceUpdatedListener = listener;
+        this.spaceUpdatedExecutor = executor == null ? this.executor : executor;
+    }
+
+    /**
+     * Returns the pose relative to an OpenXR reference space.
+     *
+     * <p>The OpenXR reference space is the space returned by {@link
+     * XrExtensions#getOpenXrActivitySpaceType()}
+     */
+    public Pose getPoseInOpenXrReferenceSpace() {
+        return openXrReferenceSpacePose;
+    }
+
+    /**
+     * Sets the pose and scale of the entity in an OpenXR reference space and should call the
+     * onSpaceUpdated() callback to signal a change in the underlying space.
+     *
+     * @param openXrReferenceSpaceTransform 4x4 transformation matrix of the entity in an OpenXR
+     *     reference space. The OpenXR reference space is of the type defined by the {@link
+     *     XrExtensions#getOpenXrActivitySpaceType()} method.
+     */
+    protected void setOpenXrReferenceSpacePose(Matrix4 openXrReferenceSpaceTransform) {
+        // TODO: b/353511649 - Make SystemSpaceEntityImpl thread safe.
+        this.openXrReferenceSpacePose =
+                Matrix4Ext.getUnscaled(openXrReferenceSpaceTransform).getPose();
+
+        // TODO: b/367780918 - Consider using Matrix4.scale when it is fixed.
+        // Retrieve the scale from the matrix. The scale can be retrieved from the matrix by getting
+        // the magnitude of one of the rows of the matrix. Note that we are assuming uniform scale.
+        // SpaceFlinger might apply a scale to the task node, for example if the user caused the
+        // main
+        // panel to scale in Homespace mode.
+        float data00 = openXrReferenceSpaceTransform.getData()[0];
+        float data01 = openXrReferenceSpaceTransform.getData()[1];
+        float data02 = openXrReferenceSpaceTransform.getData()[2];
+        float scale = (float) Math.sqrt(data00 * data00 + data01 * data01 + data02 * data02);
+        this.worldSpaceScale = new Vector3(scale, scale, scale);
+        this.setScaleInternal(new Vector3(scale, scale, scale));
+        onSpaceUpdated();
+    }
+
+    /**
+     * Subscribes to the node's transform update events and caches the pose by calling
+     * setOpenXrReferenceSpacePose().
+     *
+     * @param node The node to subscribe to.
+     * @param executor The executor to run the callback on.
+     */
+    private void subscribeToNodeTransform(Node node, Executor executor) {
+        this.nodeTransformCloseable =
+                node.subscribeToTransform(
+                        (transform) ->
+                                setOpenXrReferenceSpacePose(
+                                        RuntimeUtils.getMatrix(transform.getTransform())),
+                        executor);
+    }
+
+    @Override
+    public Vector3 getWorldSpaceScale() {
+        return this.worldSpaceScale;
+    }
+
+    /** Unsubscribes from the node's transform update events. */
+    private void unsubscribeFromNodeTransform() {
+        try {
+            this.nodeTransformCloseable.close();
+        } catch (Exception e) {
+            Log.w(
+                    "SystemSpaceEntity",
+                    "Could not close node transform subscription with error: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public void dispose() {
+        unsubscribeFromNodeTransform();
+        super.dispose();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Anchor.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Anchor.java
new file mode 100644
index 0000000..fc6d89f
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Anchor.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import java.nio.ByteBuffer;
+import java.util.UUID;
+
+/**
+ * An Anchor keeps track of a position in the real world. This can be an arbitrary position or a
+ * position relative to a trackable.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Anchor {
+    /**
+     * anchorId is an ID used as a reference to this anchor. It is equal to the XrSpace handle for
+     * anchor in the OpenXR session managed by the perception library.
+     */
+    private final long anchorId;
+
+    /**
+     * anchorToken is a Binder reference of the anchor, it can be used to import the anchor by an
+     * OpenXR session.
+     */
+    private final IBinder anchorToken;
+
+    /* UUID of the anchor.*/
+    private UUID uuid;
+
+    public Anchor(long anchorId, @NonNull IBinder anchorToken) {
+        this.anchorId = anchorId;
+        this.anchorToken = anchorToken;
+        this.uuid = null;
+    }
+
+    Anchor(AnchorData anchorData) {
+        this.anchorToken = anchorData.anchorToken;
+        this.anchorId = anchorData.anchorId;
+        this.uuid = null;
+    }
+
+    /** Returns the anchorId(native pointer) of the anchor. */
+    public long getAnchorId() {
+        return anchorId;
+    }
+
+    /**
+     * Returns an IBInder token to this anchor. This is used for sharing the anchor with other
+     * OpenXR sessions in other processes (such as SpaceFlinger).
+     */
+    @NonNull
+    public IBinder getAnchorToken() {
+        return anchorToken;
+    }
+
+    /**
+     * Detaches the anchor. Any process which has imported this anchor will see the anchor as
+     * untracked.
+     */
+    public boolean detach() {
+        return detachAnchor(anchorId);
+    }
+
+    /**
+     * Persists the anchor. When the anchor is persisted successfully, that means the environment
+     * and the anchor's relative position to the environment is saved to storage. The anchor could
+     * be recreated in the same environment in the following sessions with the original pose by
+     * calling Session.createAnchor(uuid).
+     *
+     * @return the UUID of the anchor being persisted.
+     */
+    @Nullable
+    public UUID persist() {
+        byte[] uuidBytes = persistAnchor(anchorId);
+        if (uuidBytes == null) {
+            return null;
+        }
+        ByteBuffer byteBuffer = ByteBuffer.wrap(uuidBytes);
+        long high = byteBuffer.getLong();
+        long low = byteBuffer.getLong();
+        this.uuid = new UUID(high, low);
+        return this.uuid;
+    }
+
+    private native boolean detachAnchor(long anchorId);
+
+    private native byte[] persistAnchor(long anchorId);
+
+    /**
+     * Gets the Persistent State of the anchor. If the anchor doesn't have any persist requests yet,
+     * it returns PersistState.PERSIST_NOT_REQUESTED. If the anchor was found by the uuid, it
+     * returns the current persist state of the anchor.
+     *
+     * @return the persist state of the anchor.
+     */
+    @NonNull
+    public PersistState getPersistState() {
+        if (uuid == null) {
+            return PersistState.PERSIST_NOT_REQUESTED;
+        }
+        PersistState state =
+                getPersistState(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits());
+        if (state == null) {
+            return PersistState.NOT_VALID;
+        }
+        return state;
+    }
+
+    private native PersistState getPersistState(long highBits, long lowBits);
+
+    /** Persistent State Enum for the anchor. It is retrieved with the getPersistState function. */
+    public enum PersistState {
+        PERSIST_NOT_REQUESTED,
+        PERSIST_PENDING,
+        PERSISTED,
+        NOT_VALID,
+    }
+
+    /** Data returned from native OpenXR layer when creating an anchor. */
+    static class AnchorData {
+        long anchorId;
+        IBinder anchorToken;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Fov.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Fov.java
new file mode 100644
index 0000000..0b6758e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Fov.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Represents the field of view. <a
+ * href="https://registry.khronos.org/OpenXR/specs/1.0/man/html/XrFovf.html">...</a>
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class Fov {
+    private final float angleLeft;
+    private final float angleRight;
+    private final float angleUp;
+    private final float angleDown;
+
+    public Fov(float angleLeft, float angleRight, float angleUp, float angleDown) {
+        this.angleLeft = angleLeft;
+        this.angleRight = angleRight;
+        this.angleUp = angleUp;
+        this.angleDown = angleDown;
+    }
+
+    /**
+     * Returns the angle of the left side of the field of view. For a symmetric field of view, this
+     * value is negative. *
+     */
+    public float getAngleLeft() {
+        return angleLeft;
+    }
+
+    /** Returns the angle of the right part of the field of view. */
+    public float getAngleRight() {
+        return angleRight;
+    }
+
+    /** Returns the angle of the top part of the field of view. */
+    public float getAngleUp() {
+        return angleUp;
+    }
+
+    /**
+     * Returns the angle of the bottom side of the field of view. For a symmetric field of view,
+     * this value is negative. *
+     */
+    public float getAngleDown() {
+        return angleDown;
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (object instanceof Fov) {
+            Fov that = (Fov) object;
+            return this.angleLeft == that.angleLeft
+                    && this.angleRight == that.angleRight
+                    && this.angleUp == that.angleUp
+                    && this.angleDown == that.angleDown;
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 1;
+        result = 31 * result + Float.floatToIntBits(angleLeft);
+        result = 31 * result + Float.floatToIntBits(angleRight);
+        result = 31 * result + Float.floatToIntBits(angleUp);
+        result = 31 * result + Float.floatToIntBits(angleDown);
+        return result;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibrary.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibrary.java
new file mode 100644
index 0000000..29dfaba
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibrary.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import android.app.Activity;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.concurrent.futures.ResolvableFuture;
+import androidx.xr.scenecore.impl.perception.exceptions.FailedToInitializeException;
+import androidx.xr.scenecore.impl.perception.exceptions.LibraryLoadingException;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * A library for handling perception on Jetpack XR.This will create and manage one or more OpenXR
+ * sessions and manage calls to it.
+ */
+@SuppressWarnings({"BanSynchronizedMethods", "BanConcurrentHashMap"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PerceptionLibrary {
+    private static final String TAG = "PerceptionLibrary";
+
+    private static final String NATIVE_LIBRARY_NAME = "androidx.xr.runtime.openxr";
+    private static final ConcurrentHashMap<Activity, Session> activitySessionMap =
+            new ConcurrentHashMap<>();
+
+    @SuppressWarnings("NonFinalStaticField")
+    private static volatile boolean libraryLoaded = false;
+
+    private Session session = null;
+
+    public PerceptionLibrary() {}
+
+    @SuppressWarnings("VisiblySynchronized")
+    protected static synchronized void loadLibraryAsync(@NonNull String nativeLibraryName) {
+        if (libraryLoaded) {
+            return;
+        }
+        Log.i(TAG, "Loading native library: " + nativeLibraryName);
+        try {
+            System.loadLibrary(nativeLibraryName);
+        } catch (UnsatisfiedLinkError e) {
+            Log.e(TAG, "Unable to load " + nativeLibraryName);
+            return;
+        }
+        libraryLoaded = true;
+    }
+
+    /**
+     * Initializes a new session. It is an error to call this if there is already a session
+     * associated with the activity.
+     *
+     * @param activity This activity to associate with the OpenXR session.
+     * @param referenceSpaceType The base space type of the session.
+     * @param executor This executor is used to poll the OpenXR event loop.
+     * @return a new Session or null if there was an error creating the session.
+     * @throws java.util.concurrent.ExecutionException if there was an error to initialize the
+     *     session. The cause of the ExecutionException will be an IllegalStateException if a valid
+     *     session already exists, LibraryLoadingException if the internal native library failed to
+     *     load, and a FailedToInitializeException if there was a failure to initialize the session
+     *     internally.
+     */
+    // ResolvableFuture is marked as RestrictTo(LIBRARY_GROUP_PREFIX), which is intended for classes
+    // within AndroidX. We're in the process of migrating to AndroidX. Without suppressing this
+    // warning, however, we get a build error - go/bugpattern/RestrictTo.
+    @SuppressWarnings({"RestrictTo", "AsyncSuffixFuture"})
+    @Nullable
+    public ListenableFuture<Session> initSession(
+            @NonNull Activity activity,
+            @PerceptionLibraryConstants.OpenXrSpaceType int referenceSpaceType,
+            @NonNull ExecutorService executor) {
+
+        ResolvableFuture<Session> future = ResolvableFuture.create();
+        executor.execute(
+                () -> {
+                    // TODO: b/373922954 - Early out of some of these operations if the future is
+                    // cancelled.
+                    if (!libraryLoaded) {
+                        loadLibraryAsync(NATIVE_LIBRARY_NAME);
+                    }
+                    if (!libraryLoaded) {
+                        Log.i(TAG, "Cannot init session since the native library failed to load.");
+                        future.setException(
+                                new LibraryLoadingException("Native library failed to load."));
+                        return;
+                    }
+                    Log.i(TAG, stringFromJNI());
+                    if (this.session != null) {
+                        future.setException(new IllegalStateException("Session already exists."));
+                        return;
+                    }
+                    if (activitySessionMap.containsKey(activity)) {
+                        future.setException(
+                                new IllegalStateException(
+                                        "Session already exists for the provided activity."));
+                        return;
+                    }
+                    Session session = new Session(activity, referenceSpaceType, executor);
+                    if (!session.initSession()) {
+                        Log.e(TAG, "Failed to initialize a session.");
+                        future.setException(
+                                new FailedToInitializeException("Failed to initialize a session."));
+                        return;
+                    }
+
+                    Log.i(TAG, "Loaded perception library.");
+                    // Do another check to make sure another session wasn't created for this
+                    // activity
+                    // while we were initializing it.
+                    if (activitySessionMap.putIfAbsent(activity, session) != null) {
+                        future.setException(
+                                new IllegalStateException(
+                                        "Session already exists for the provided activity."));
+                    }
+                    this.session = session;
+                    future.set(this.session);
+                });
+        return future;
+    }
+
+    /** Returns the previously created session or null. */
+    @SuppressWarnings("VisiblySynchronized")
+    @Nullable
+    public synchronized Session getSession() {
+        return session;
+    }
+
+    /**
+     * JNI function that returns a string. This is a temporary function to validate the JNI
+     * implementation.
+     */
+    private native String stringFromJNI();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryConstants.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryConstants.java
new file mode 100644
index 0000000..62f1486
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryConstants.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Class to manage constants for the PerceptionLibrary. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class PerceptionLibraryConstants {
+
+    /** View OpenXR Reference Space Type. */
+    public static final int OPEN_XR_SPACE_TYPE_VIEW = 1;
+
+    /** Local OpenXR Reference Space Type. */
+    public static final int OPEN_XR_SPACE_TYPE_LOCAL = 2;
+
+    /** Stage OpenXR Reference Space Type. */
+    public static final int OPEN_XR_SPACE_TYPE_STAGE = 3;
+
+    /** Local Floor OpenXR Reference Space Type. */
+    public static final int OPEN_XR_SPACE_TYPE_LOCAL_FLOOR = 1000426000;
+
+    /** Unbounded OpenXR Reference Space Type. */
+    public static final int OPEN_XR_SPACE_TYPE_UNBOUNDED = 1000467000;
+
+    /** IntDef for OpenXR Reference Space Types. */
+    @IntDef({
+        OPEN_XR_SPACE_TYPE_VIEW,
+        OPEN_XR_SPACE_TYPE_LOCAL,
+        OPEN_XR_SPACE_TYPE_STAGE,
+        OPEN_XR_SPACE_TYPE_LOCAL_FLOOR,
+        OPEN_XR_SPACE_TYPE_UNBOUNDED
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    @interface OpenXrSpaceType {}
+
+    private PerceptionLibraryConstants() {}
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Plane.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Plane.java
new file mode 100644
index 0000000..6c58b8d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Plane.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A Plane is a type of Trackable that maps to a real world plane (e.g. a floor, a wall, or a table)
+ */
+// TODO: b/329875042 - Add a utility to convert this to an ARCore plane.
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Plane implements Trackable {
+    private static final String TAG = "PerceptionPlane";
+    ArrayList<Anchor> attachedAnchors = new ArrayList<>();
+    Long planeId = 0L;
+    @PerceptionLibraryConstants.OpenXrSpaceType int referenceSpaceType = 0;
+
+    public Plane(
+            @SuppressWarnings("AutoBoxing") @NonNull Long planeId,
+            @PerceptionLibraryConstants.OpenXrSpaceType int referenceSpaceType) {
+        this.planeId = planeId;
+        this.referenceSpaceType = referenceSpaceType;
+    }
+
+    /**
+     * Creates an anchor on the trackable for a point in world space relative to the trackable.
+     *
+     * @param pose Specifies the pose relative to the center point of the trackable.
+     * @param timeNs The monotonic time retrieved using the SystemClock's uptimeMillis() or
+     *     uptimeNanos() at which to get the pose, in nanoseconds. If time is null or negative, the
+     *     current time will be used.
+     */
+    @Override
+    @Nullable
+    public Anchor createAnchor(
+            @NonNull Pose pose, @SuppressWarnings("AutoBoxing") @Nullable Long timeNs) {
+        Anchor.AnchorData anchorData =
+                createAnchorOnPlane(planeId, pose, timeNs == null ? -1 : timeNs);
+        if (anchorData == null) {
+            Log.i(TAG, "Failed to create an anchor.");
+            return null;
+        }
+        Log.i(TAG, "Creating an anchor result:" + anchorData.anchorToken);
+        Anchor anchor = new Anchor(anchorData);
+        attachedAnchors.add(anchor);
+        return anchor;
+    }
+
+    /**
+     * Retrieves data associated with the plane.
+     *
+     * @param timeNs The monotonic time retrieved using the SystemClock's uptimeMillis() or
+     *     uptimeNanos() at which to get the pose, in nanoseconds. If time is null or negative, the
+     *     current time will be used.
+     */
+    @Nullable
+    public PlaneData getData(@SuppressWarnings("AutoBoxing") @Nullable Long timeNs) {
+        return getPlaneData(planeId, referenceSpaceType, timeNs == null ? -1 : timeNs);
+    }
+
+    /** Returns all anchors attached to this trackable. */
+    @NonNull
+    @Override
+    public List<Anchor> getAnchors() {
+        return attachedAnchors;
+    }
+
+    private native PlaneData getPlaneData(
+            long planeId, int referenceSpaceType, long monotonicTimeNs);
+
+    private native Anchor.AnchorData createAnchorOnPlane(
+            long planeId, Pose pose, long monotonicTimeNs);
+
+    /**
+     * The direction of the plane. These enum values must be kept in sync with OpenXR
+     * XrPlaneTypeANDROID.
+     */
+    public enum Type {
+        /** A horizontal plane facing downward (e.g. a ceiling). */
+        HORIZONTAL_DOWNWARD_FACING(0),
+        /** A horizontal plane facing upward (e.g. floor or tabletop). */
+        HORIZONTAL_UPWARD_FACING(1),
+        /** A vertical plane (e.g. a wall). */
+        VERTICAL(2),
+        /** Any plane type. */
+        ARBITRARY(3);
+
+        public final int intValue;
+
+        Type(int intValue) {
+            this.intValue = intValue;
+        }
+
+        // Converts a native code to the corresponding enum value.
+        static Type forNumber(int intValue) {
+            for (Type type : values()) {
+                if (type.intValue == intValue) {
+                    return type;
+                }
+            }
+            return ARBITRARY;
+        }
+    }
+
+    /**
+     * Data on the plane that can be used to determine the type of object that it is. These enum
+     * values must be kept in sync with OpenXR XrPlaneLabelANDROID.
+     */
+    public enum Label {
+        /** It was not possible to label the plane. */
+        UNKNOWN(0),
+        /** The plane is a wall. */
+        WALL(1),
+        /** The plane is a floor. */
+        FLOOR(2),
+        /** The plane is a ceiling */
+        CEILING(3),
+        /** The plane is a table */
+        TABLE(4);
+
+        public final int intValue;
+
+        Label(int intValue) {
+            this.intValue = intValue;
+        }
+
+        // Converts a native code to the corresponding enum value.
+        static Label forNumber(int intValue) {
+            for (Label label : values()) {
+                if (label.intValue == intValue) {
+                    return label;
+                }
+            }
+            return UNKNOWN;
+        }
+    }
+
+    /** Data returned from the native OpenXR to describe the plane. */
+    public static class PlaneData {
+        /**
+         * The pose of the center of the plane. The positive y-axis of the pose will point
+         * perpendicular out of the plane's surface.
+         */
+        @NonNull public final Pose centerPose;
+
+        /** The width of the plane. */
+        public final float extentWidth;
+
+        /** The height of the plane. */
+        public final float extentHeight;
+
+        /** The direction of the plane. */
+        @NonNull public final Plane.Type type;
+
+        /** A label that can be used to determine the type of object that the plane is. */
+        @NonNull public final Plane.Label label;
+
+        public PlaneData(
+                @NonNull Pose centerPose,
+                float extentWidth,
+                float extentHeight,
+                int type,
+                int label) {
+            this.centerPose = centerPose;
+            this.extentWidth = extentWidth;
+            this.extentHeight = extentHeight;
+            this.type = Plane.Type.forNumber(type);
+            this.label = Plane.Label.forNumber(label);
+        }
+
+        @Override
+        public boolean equals(Object object) {
+            if (object instanceof PlaneData) {
+                PlaneData that = (PlaneData) object;
+                return this.centerPose.equals(that.centerPose)
+                        && this.extentWidth == that.extentWidth
+                        && this.extentHeight == that.extentHeight
+                        && this.type == that.type
+                        && this.label == that.label;
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            int result = 1;
+            result = 31 * result + centerPose.hashCode();
+            result = 31 * result + Float.floatToIntBits(extentWidth);
+            result = 31 * result + Float.floatToIntBits(extentHeight);
+            result = 31 * result + type.hashCode();
+            result = 31 * result + label.hashCode();
+            return result;
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Pose.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Pose.java
new file mode 100644
index 0000000..48a5d40b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Pose.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** A translation and rotation of an object (e.g. Trackable, Anchor). */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class Pose {
+    private float tx;
+    private float ty;
+    private float tz;
+    private float qx;
+    private float qy;
+    private float qz;
+    private float qw;
+
+    public Pose(float tx, float ty, float tz, float qx, float qy, float qz, float qw) {
+        this.tx = tx;
+        this.ty = ty;
+        this.tz = tz;
+        this.qx = qx;
+        this.qy = qy;
+        this.qz = qz;
+        this.qw = qw;
+    }
+
+    // A pose at zero position and orientation.
+    @NonNull
+    public static Pose identity() {
+        return new Pose(0, 0, 0, 0, 0, 0, 1);
+    }
+
+    public void updateTranslation(float tx, float ty, float tz) {
+        this.tx = tx;
+        this.ty = ty;
+        this.tz = tz;
+    }
+
+    public void updateRotation(float qx, float qy, float qz, float qw) {
+        this.qx = qx;
+        this.qy = qy;
+        this.qz = qz;
+        this.qw = qw;
+    }
+
+    /** Returns the X components of this pose's translation. */
+    public float tx() {
+        return tx;
+    }
+
+    /** Returns the Y components of this pose's translation. */
+    public float ty() {
+        return ty;
+    }
+
+    /** Returns the Z components of this pose's translation. */
+    public float tz() {
+        return tz;
+    }
+
+    /** Returns the X component of this pose's rotation quaternion. */
+    public float qx() {
+        return qx;
+    }
+
+    /** Returns the Y component of this pose's rotation quaternion. */
+    public float qy() {
+        return qy;
+    }
+
+    /** Returns the Z component of this pose's rotation quaternion. */
+    public float qz() {
+        return qz;
+    }
+
+    /** Returns the W component of this pose's rotation quaternion. */
+    public float qw() {
+        return qw;
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (object instanceof Pose) {
+            Pose that = (Pose) object;
+            return this.tx == that.tx
+                    && this.ty == that.ty
+                    && this.tz == that.tz
+                    && this.qx == that.qx
+                    && this.qy == that.qy
+                    && this.qz == that.qz
+                    && this.qw == that.qw;
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 1;
+        result = 31 * result + Float.floatToIntBits(tx);
+        result = 31 * result + Float.floatToIntBits(ty);
+        result = 31 * result + Float.floatToIntBits(tz);
+        result = 31 * result + Float.floatToIntBits(qx);
+        result = 31 * result + Float.floatToIntBits(qy);
+        result = 31 * result + Float.floatToIntBits(qz);
+        result = 31 * result + Float.floatToIntBits(qw);
+        return result;
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Session.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Session.java
new file mode 100644
index 0000000..ff61a8f
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Session.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import static java.util.stream.Collectors.toCollection;
+
+import android.app.Activity;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+
+/** A perception session is used to manage and call into the OpenXR session */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Session {
+    /** Java constant representation of OpenXR's XR_NULL_HANDLE. */
+    public static final long XR_NULL_HANDLE = 0;
+
+    private static final String TAG = "PerceptionSession";
+    Activity activity;
+    @PerceptionLibraryConstants.OpenXrSpaceType int openXrReferenceSpaceType;
+    Executor executor;
+    HashMap<Long, Plane> foundPlanes = new HashMap<>();
+
+    Session(
+            Activity activity,
+            @PerceptionLibraryConstants.OpenXrSpaceType int openXrReferenceSpaceType,
+            Executor executor) {
+        this.activity = activity;
+        this.openXrReferenceSpaceType = openXrReferenceSpaceType;
+        this.executor = executor;
+    }
+
+    boolean initSession() {
+        boolean xrLoaded = createOpenXrSession(activity, openXrReferenceSpaceType);
+        if (!xrLoaded) {
+            Log.e(TAG, "Failed to load OpenXR session.");
+            return false;
+        }
+        return true;
+    }
+
+    /** Creates an anchor for the specified plane type. */
+    @Nullable
+    public Anchor createAnchor(
+            float minWidth, float minHeight, @NonNull Plane.Type type, @NonNull Plane.Label label) {
+        Anchor.AnchorData anchorData =
+                getAnchor(minWidth, minHeight, type.intValue, label.intValue);
+        if (anchorData == null) {
+            Log.i(TAG, "Failed to create an anchor.");
+            return null;
+        }
+        Log.i(TAG, "Creating an anchor result:" + anchorData.anchorToken);
+        return new Anchor(anchorData);
+    }
+
+    /**
+     * Recreates a persisted anchor for the specified UUID from the storage.
+     *
+     * @return the anchor if the operation succeeds.
+     */
+    @Nullable
+    public Anchor createAnchorFromUuid(@Nullable UUID uuid) {
+        if (uuid == null) {
+            Log.i(TAG, "UUID is null and cannot create a persisted anchor.");
+            return null;
+        }
+        Anchor.AnchorData anchorData =
+                createPersistedAnchor(
+                        uuid.getMostSignificantBits(), uuid.getLeastSignificantBits());
+        if (anchorData == null) {
+            Log.i(TAG, "Failed to create a persisted anchor.");
+            return null;
+        }
+        Log.i(TAG, "Creating a persisted anchor result:" + anchorData.anchorToken);
+        return new Anchor(anchorData);
+    }
+
+    /**
+     * Returns all planes that can be found in the scene. The order is not guaranteed to be
+     * consistent. An anchor can be created from a plane object and it will be tied to that plane.
+     */
+    @NonNull
+    public List<Plane> getAllPlanes() {
+        return getPlanes().stream()
+                .map(
+                        nativeId ->
+                                foundPlanes.computeIfAbsent(
+                                        nativeId,
+                                        id -> new Plane(nativeId, openXrReferenceSpaceType)))
+                .collect(toCollection(ArrayList::new));
+    }
+
+    /** Returns the current head pose using the current timestamp in OpenXR. */
+    @Nullable
+    public Pose getHeadPose() {
+        Pose pose = getCurrentHeadPose();
+        if (pose == null) {
+            Log.w(TAG, "Failed to get the head pose.");
+            return null;
+        }
+        return pose;
+    }
+
+    /**
+     * Returns the left and right views mapping to the left and right eyes using the current
+     * timestamp from OpenXR.
+     */
+    @Nullable
+    public ViewProjections getStereoViews() {
+        Pair<ViewProjection, ViewProjection> stereoViews = getCurrentStereoViews();
+        if (stereoViews == null) {
+            Log.w(TAG, "Failed to get stereo views.");
+            return null;
+        }
+        return new ViewProjections(stereoViews.first, stereoViews.second);
+    }
+
+    /** Get the underlying OpenXR XrSession handle. */
+    public native long getNativeSession();
+
+    /** Get the underlying OpenXR XrInstance handle. */
+    public native long getNativeInstance();
+
+    private native List<Long> getPlanes();
+
+    private native Pose getCurrentHeadPose();
+
+    private native Pair<ViewProjection, ViewProjection> getCurrentStereoViews();
+
+    private native boolean createOpenXrSession(Activity activity, int referenceSpaceType);
+
+    private native Anchor.AnchorData getAnchor(
+            float minWidth, float minHeight, int type, int label);
+
+    private native Anchor.AnchorData createPersistedAnchor(long highBits, long lowBits);
+
+    /**
+     * Unpersists an anchor for the specified UUID.
+     *
+     * @return whether the unpersist operation succeeds or not.
+     */
+    public boolean unpersistAnchor(@Nullable UUID uuid) {
+        if (uuid == null) {
+            Log.i(TAG, "UUID is null and cannot unpersist the anchor.");
+            return false;
+        }
+        return unpersistAnchor(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits());
+    }
+
+    private native boolean unpersistAnchor(long highBits, long lowBits);
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Trackable.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Trackable.java
new file mode 100644
index 0000000..ccc0b16
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/Trackable.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+import java.util.List;
+
+/**
+ * A Trackable is something that can be tracked in the real world (e.g. a plane). An anchor can be
+ * attached to a trackable in order to keep track of a point relative to that trackable.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public interface Trackable {
+
+    /**
+     * Creates an anchor on the trackable for a point in the specified reference space.
+     *
+     * @param pose Specifies the pose relative to the center point of the trackable.
+     * @param timeNs The monotonic time retrieved using the SystemClock's uptimeMillis() or
+     *     uptimeNanos() at which to get the pose, in nanoseconds. If time is null or negative, the
+     *     current time will be used.
+     */
+    @Nullable
+    Anchor createAnchor(@NonNull Pose pose, @SuppressWarnings("AutoBoxing") @Nullable Long timeNs);
+
+    /** Returns all anchors attached to this trackable. */
+    @NonNull
+    List<Anchor> getAnchors();
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjection.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjection.java
new file mode 100644
index 0000000..6b61de7
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjection.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/**
+ * Contains the view projection state. <a
+ * href="https://registry.khronos.org/OpenXR/specs/1.0/man/html/XrView.html">...</a>
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ViewProjection {
+    private final Pose pose;
+    private final Fov fov;
+
+    public ViewProjection(@NonNull Pose pose, @NonNull Fov fov) {
+        this.pose = pose;
+        this.fov = fov;
+    }
+
+    /** Returns the location and orientation of the camera/eye pose. */
+    @NonNull
+    public Pose getPose() {
+        return pose;
+    }
+
+    /** Returns the four sides of the projection / view frustum. */
+    @NonNull
+    public Fov getFov() {
+        return fov;
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (object instanceof ViewProjection) {
+            ViewProjection that = (ViewProjection) object;
+            return this.pose.equals(that.pose) && this.fov.equals(that.fov);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return pose.hashCode() + fov.hashCode();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjections.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjections.java
new file mode 100644
index 0000000..803e0e0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/ViewProjections.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Contains the view projections for both eyes */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class ViewProjections {
+    private final ViewProjection leftEye;
+    private final ViewProjection rightEye;
+
+    public ViewProjections(@NonNull ViewProjection leftEye, @NonNull ViewProjection rightEye) {
+        this.leftEye = leftEye;
+        this.rightEye = rightEye;
+    }
+
+    // Returns the left eye view projection.
+    @NonNull
+    public ViewProjection getLeftEye() {
+        return leftEye;
+    }
+
+    // Returns the right eye view projection.
+    @NonNull
+    public ViewProjection getRightEye() {
+        return rightEye;
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (object instanceof ViewProjections) {
+            ViewProjections that = (ViewProjections) object;
+            return this.leftEye.equals(that.leftEye) && this.rightEye.equals(that.rightEye);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return leftEye.hashCode() + rightEye.hashCode();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/FailedToInitializeException.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/FailedToInitializeException.java
new file mode 100644
index 0000000..07a2710
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/FailedToInitializeException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception.exceptions;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Thrown if there is a failure in initializing a PerceptionLibrary Session. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class FailedToInitializeException extends RuntimeException {
+
+    public FailedToInitializeException(@NonNull String message) {
+        super(message);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/LibraryLoadingException.java b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/LibraryLoadingException.java
new file mode 100644
index 0000000..4de142a
--- /dev/null
+++ b/xr/scenecore/scenecore/src/main/java/androidx/xr/scenecore/impl/perception/exceptions/LibraryLoadingException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception.exceptions;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/** Thrown if there is a failure in loading a native library. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class LibraryLoadingException extends RuntimeException {
+
+    public LibraryLoadingException(@NonNull String message) {
+        super(message);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ActivityPoseTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ActivityPoseTest.kt
new file mode 100644
index 0000000..bf44e6e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ActivityPoseTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.xr.runtime.math.Pose
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.any
+import org.mockito.Mockito.anyInt
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ActivityPoseTest {
+    private val entityManager = EntityManager()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private val mockActivitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+    private val mockHeadActivityPose = mock<JxrPlatformAdapter.HeadActivityPose>()
+    private val mockCameraViewActivityPose = mock<JxrPlatformAdapter.CameraViewActivityPose>()
+    private val mockPerceptionSpaceActivityPose =
+        mock<JxrPlatformAdapter.PerceptionSpaceActivityPose>()
+
+    private lateinit var spatialUser: SpatialUser
+    private var head: Head? = null
+    private lateinit var activitySpace: ActivitySpace
+    private var camera: CameraView? = null
+    private lateinit var perceptionSpace: PerceptionSpace
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.headActivityPose).thenReturn(mockHeadActivityPose)
+        whenever(mockRuntime.getCameraViewActivityPose(anyInt()))
+            .thenReturn(mockCameraViewActivityPose)
+        whenever(mockRuntime.activitySpace).thenReturn(mockActivitySpace)
+        whenever(mockRuntime.perceptionSpaceActivityPose)
+            .thenReturn(mockPerceptionSpaceActivityPose)
+        head = Head.create(mockRuntime)
+        camera = CameraView.createLeft(mockRuntime)
+        activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+        perceptionSpace = PerceptionSpace.create(mockRuntime)
+    }
+
+    @Test
+    fun allActivityPoseTransformPoseTo_callsRuntimeActivityPoseImplTransformPoseTo() {
+        whenever(mockHeadActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockCameraViewActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockPerceptionSpaceActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(head?.transformPoseTo(pose, head!!)).isEqualTo(pose)
+        assertThat(camera!!.transformPoseTo(pose, camera!!)).isEqualTo(pose)
+        assertThat(perceptionSpace.transformPoseTo(pose, perceptionSpace)).isEqualTo(pose)
+
+        verify(mockHeadActivityPose).transformPoseTo(any(), any())
+        verify(mockCameraViewActivityPose).transformPoseTo(any(), any())
+    }
+
+    @Test
+    fun allActivityPoseTransformPoseToEntity_callsRuntimeActivityPoseImplTransformPoseTo() {
+        whenever(mockHeadActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockCameraViewActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockPerceptionSpaceActivityPose.transformPoseTo(any(), any())).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(head!!.transformPoseTo(pose, activitySpace)).isEqualTo(pose)
+        assertThat(camera!!.transformPoseTo(pose, activitySpace)).isEqualTo(pose)
+        assertThat(perceptionSpace.transformPoseTo(pose, activitySpace)).isEqualTo(pose)
+
+        verify(mockHeadActivityPose).transformPoseTo(any(), any())
+        verify(mockCameraViewActivityPose).transformPoseTo(any(), any())
+        verify(mockPerceptionSpaceActivityPose).transformPoseTo(any(), any())
+    }
+
+    @Test
+    fun allActivityPoseTransformPoseFromEntity_callsRuntimeActivityPoseImplTransformPoseTo() {
+        whenever(mockActivitySpace.transformPoseTo(any(), any())).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(activitySpace.transformPoseTo(pose, head!!)).isEqualTo(pose)
+        assertThat(activitySpace.transformPoseTo(pose, camera!!)).isEqualTo(pose)
+        assertThat(activitySpace.transformPoseTo(pose, perceptionSpace)).isEqualTo(pose)
+
+        verify(mockActivitySpace, times(3)).transformPoseTo(any(), any())
+    }
+
+    @Test
+    fun allActivityPoseGetActivitySpacePose_callsRuntimeActivityPoseImplGetActivitySpacePose() {
+        whenever(mockHeadActivityPose.activitySpacePose).thenReturn(Pose())
+        whenever(mockCameraViewActivityPose.activitySpacePose).thenReturn(Pose())
+        whenever(mockPerceptionSpaceActivityPose.activitySpacePose).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(head!!.getActivitySpacePose()).isEqualTo(pose)
+        assertThat(camera!!.getActivitySpacePose()).isEqualTo(pose)
+        assertThat(perceptionSpace.getActivitySpacePose()).isEqualTo(pose)
+
+        verify(mockHeadActivityPose).activitySpacePose
+        verify(mockCameraViewActivityPose).activitySpacePose
+        verify(mockPerceptionSpaceActivityPose).activitySpacePose
+    }
+
+    @Test
+    fun cameraView_getFov_returnsFov() {
+        val rtFov = JxrPlatformAdapter.CameraViewActivityPose.Fov(1f, 2f, 3f, 4f)
+        whenever(mockCameraViewActivityPose.fov).thenReturn(rtFov)
+
+        assertThat(camera!!.fov).isEqualTo(Fov(1f, 2f, 3f, 4f))
+
+        verify(mockCameraViewActivityPose).fov
+    }
+
+    @Test
+    fun cameraView_getFovTwice_returnsUpdatedFov() {
+        val rtFov = JxrPlatformAdapter.CameraViewActivityPose.Fov(1f, 2f, 3f, 4f)
+        whenever(mockCameraViewActivityPose.fov)
+            .thenReturn(rtFov)
+            .thenReturn(JxrPlatformAdapter.CameraViewActivityPose.Fov(5f, 6f, 7f, 8f))
+
+        assertThat(camera!!.fov).isEqualTo(Fov(1f, 2f, 3f, 4f))
+        assertThat(camera!!.fov).isEqualTo(Fov(5f, 6f, 7f, 8f))
+
+        verify(mockCameraViewActivityPose, times(2)).fov
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityManagerTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityManagerTest.kt
new file mode 100644
index 0000000..6a7ac4c
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityManagerTest.kt
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.widget.TextView
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.Futures
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.toJavaDuration
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class EntityManagerTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private val mockGltfModelEntityImpl = mock<JxrPlatformAdapter.GltfEntity>()
+    private val mockPanelEntityImpl = mock<JxrPlatformAdapter.PanelEntity>()
+    private val mockAnchorEntityImpl = mock<JxrPlatformAdapter.AnchorEntity>()
+    private val mockActivityPanelEntity = mock<JxrPlatformAdapter.ActivityPanelEntity>()
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val entityManager = EntityManager()
+    private lateinit var session: Session
+    private lateinit var activitySpace: ActivitySpace
+    private lateinit var gltfModel: GltfModel
+    private lateinit var gltfModelEntity: GltfModelEntity
+    private lateinit var panelEntity: PanelEntity
+    private lateinit var anchorEntity: AnchorEntity
+    private lateinit var activityPanelEntity: ActivityPanelEntity
+    private lateinit var contentlessEntity: Entity
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(mock())
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.loadGltfByAssetNameSplitEngine(Mockito.anyString()))
+            .thenReturn(Futures.immediateFuture(mock()))
+        whenever(mockRuntime.createGltfEntity(any(), any(), any()))
+            .thenReturn(mockGltfModelEntityImpl)
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockPanelEntityImpl)
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), any()))
+            .thenReturn(mockAnchorEntityImpl)
+        whenever(mockAnchorEntityImpl.state)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.State.UNANCHORED)
+        whenever(mockAnchorEntityImpl.persistState)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_NOT_REQUESTED)
+        whenever(mockRuntime.createActivityPanelEntity(any(), any(), any(), any(), any()))
+            .thenReturn(mockActivityPanelEntity)
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockContentlessEntity)
+        whenever(mockRuntime.mainPanelEntity).thenReturn(mockPanelEntityImpl)
+        session = Session.create(activity, mockRuntime)
+        activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+    }
+
+    @Test
+    fun creatingEntity_addsEntityToEntityManager() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+
+        // The entityManager contains activity space.
+        assertThat(entityManager.getAllEntities().size).isAtLeast(5)
+        assertThat(entityManager.getAllEntities())
+            .containsAtLeast(
+                contentlessEntity,
+                panelEntity,
+                anchorEntity,
+                activityPanelEntity,
+                gltfModelEntity,
+            )
+    }
+
+    @Test
+    fun getEntityForRtEntity_returnsEntity() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+
+        assertThat(entityManager.getEntityForRtEntity(mockContentlessEntity))
+            .isEqualTo(contentlessEntity)
+        assertThat(entityManager.getEntityForRtEntity(mockPanelEntityImpl)).isEqualTo(panelEntity)
+        assertThat(entityManager.getEntityForRtEntity(mockAnchorEntityImpl)).isEqualTo(anchorEntity)
+        assertThat(entityManager.getEntityForRtEntity(mockGltfModelEntityImpl))
+            .isEqualTo(gltfModelEntity)
+        assertThat(entityManager.getEntityForRtEntity(mockActivityPanelEntity))
+            .isEqualTo(activityPanelEntity)
+    }
+
+    @Test
+    fun getEntityForRtEntity_returnsNullWhenNoRtEntityFound() {
+        assertThat(entityManager.getEntityForRtEntity(mockContentlessEntity)).isNull()
+        assertThat(entityManager.getEntityForRtEntity(mockPanelEntityImpl)).isNull()
+        assertThat(entityManager.getEntityForRtEntity(mockAnchorEntityImpl)).isNull()
+        assertThat(entityManager.getEntityForRtEntity(mockGltfModelEntityImpl)).isNull()
+        assertThat(entityManager.getEntityForRtEntity(mockActivityPanelEntity)).isNull()
+    }
+
+    @Test
+    fun getEntityByType_returnsEntityOfType() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+
+        assertThat(entityManager.getEntities<Entity>())
+            .containsAtLeast(
+                contentlessEntity,
+                panelEntity,
+                anchorEntity,
+                activityPanelEntity,
+                gltfModelEntity,
+            )
+        assertThat(entityManager.getEntities<ContentlessEntity>())
+            .containsExactly(contentlessEntity)
+        assertThat(entityManager.getEntities<PanelEntity>()).contains(panelEntity)
+        assertThat(entityManager.getEntities<AnchorEntity>()).containsExactly(anchorEntity)
+        assertThat(entityManager.getEntities<ActivityPanelEntity>())
+            .containsExactly(activityPanelEntity)
+        assertThat(entityManager.getEntities<GltfModelEntity>()).containsExactly(gltfModelEntity)
+    }
+
+    @Test
+    fun disposeEntity_removesEntityFromEntityManager() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+        assertThat(entityManager.getAllEntities().size).isAtLeast(5)
+        assertThat(entityManager.getAllEntities())
+            .containsAtLeast(
+                contentlessEntity,
+                panelEntity,
+                anchorEntity,
+                activityPanelEntity,
+                gltfModelEntity,
+            )
+
+        contentlessEntity.dispose()
+
+        assertThat(entityManager.getAllEntities().size).isAtLeast(4)
+        assertThat(entityManager.getAllEntities()).doesNotContain(contentlessEntity)
+    }
+
+    @Test
+    fun clearEntityManager_removesAllEntityFromEntityManager() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+        assertThat(entityManager.getAllEntities().size).isAtLeast(5)
+        assertThat(entityManager.getAllEntities())
+            .containsAtLeast(
+                contentlessEntity,
+                panelEntity,
+                anchorEntity,
+                activityPanelEntity,
+                gltfModelEntity,
+            )
+
+        entityManager.clear()
+
+        assertThat(entityManager.getAllEntities()).isEmpty()
+    }
+
+    @Test
+    fun removeRtEntity_removesEntityFromEntityManager() {
+        createContentlessEntity()
+        createPanelEntity()
+        createAnchorEntity()
+        createActivityPanelEntity()
+        createGltfEntity()
+        assertThat(entityManager.getAllEntities().size).isAtLeast(5)
+        assertThat(entityManager.getAllEntities())
+            .containsAtLeast(
+                contentlessEntity,
+                panelEntity,
+                anchorEntity,
+                activityPanelEntity,
+                gltfModelEntity,
+            )
+
+        entityManager.removeEntity(mockContentlessEntity)
+
+        assertThat(entityManager.getAllEntities().size).isAtLeast(4)
+        assertThat(entityManager.getAllEntities()).doesNotContain(contentlessEntity)
+    }
+
+    private fun createPanelEntity() {
+        panelEntity =
+            PanelEntity.create(
+                mockRuntime,
+                entityManager,
+                TextView(activity),
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test",
+                activity,
+            )
+    }
+
+    private fun createGltfEntity() {
+        gltfModel = session.createGltfResourceAsync("test.glb").get()
+        gltfModelEntity = GltfModelEntity.create(mockRuntime, entityManager, gltfModel)
+    }
+
+    private fun createAnchorEntity() {
+        anchorEntity =
+            AnchorEntity.create(
+                mockRuntime,
+                entityManager,
+                Dimensions(),
+                PlaneType.ANY,
+                PlaneSemantic.ANY,
+                10.seconds.toJavaDuration(),
+            )
+    }
+
+    private fun createActivityPanelEntity() {
+        activityPanelEntity =
+            ActivityPanelEntity.create(
+                mockRuntime,
+                entityManager,
+                PixelDimensions(640, 480),
+                "test",
+                activity,
+            )
+    }
+
+    private fun createContentlessEntity() {
+        contentlessEntity = ContentlessEntity.create(mockRuntime, entityManager, "test")
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityTest.kt
new file mode 100644
index 0000000..24c74dd
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/EntityTest.kt
@@ -0,0 +1,1137 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.content.Intent
+import android.widget.TextView
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import java.util.UUID
+import java.util.concurrent.Executor
+import java.util.function.Consumer
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.toJavaDuration
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+// TODO: b/329902726 - Add a fake runtime and verify CPM integration.
+// TODO: b/369199417 - Update EntityTest once createGltfResourceAsync is default.
+@RunWith(RobolectricTestRunner::class)
+class EntityTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private val mockGltfModelEntityImpl = mock<JxrPlatformAdapter.GltfEntity>()
+    private val mockPanelEntityImpl = mock<JxrPlatformAdapter.PanelEntity>()
+    private val mockAnchorEntityImpl = mock<JxrPlatformAdapter.AnchorEntity>()
+    private val mockActivityPanelEntity = mock<JxrPlatformAdapter.ActivityPanelEntity>()
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val entityManager = EntityManager()
+    private lateinit var session: Session
+    private lateinit var activitySpace: ActivitySpace
+    private lateinit var gltfModel: GltfModel
+    private lateinit var gltfModelEntity: GltfModelEntity
+    private lateinit var panelEntity: PanelEntity
+    private lateinit var anchorEntity: AnchorEntity
+    private lateinit var activityPanelEntity: ActivityPanelEntity
+    private lateinit var contentlessEntity: Entity
+
+    interface FakeComponent : Component
+
+    interface SubtypeFakeComponent : FakeComponent
+
+    // Introduce a test Runtime ActivitySpace to test the bounds changed callback.
+    class TestRtActivitySpace : JxrPlatformAdapter.ActivitySpace {
+        private var boundsChangedListener:
+            JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener? =
+            null
+        var getBoundsCalled: Boolean = false
+        var setScaleCalled: Boolean = false
+        var getScaleCalled: Boolean = false
+        var setAlphaCalled: Boolean = false
+        var getAlphaCalled: Boolean = false
+        var setHiddenCalled: Boolean = false
+        var getHiddenCalled: Boolean = false
+        var getActivitySpaceAlphaCalled: Boolean = false
+        var getWorldSpaceScaleCalled: Boolean = false
+        var getActivitySpaceScaleCalled: Boolean = false
+
+        override fun setPose(pose: Pose) {}
+
+        override fun getPose(): Pose {
+            return Pose()
+        }
+
+        override fun getActivitySpacePose(): Pose {
+            return Pose()
+        }
+
+        override fun transformPoseTo(
+            pose: Pose,
+            destination: JxrPlatformAdapter.ActivityPose
+        ): Pose {
+            return Pose()
+        }
+
+        override fun setSize(dimensions: JxrPlatformAdapter.Dimensions) {}
+
+        override fun getAlpha(): Float = 1.0f.apply { getAlphaCalled = true }
+
+        override fun setAlpha(alpha: Float) {
+            setAlphaCalled = true
+        }
+
+        override fun isHidden(includeParents: Boolean): Boolean =
+            false.apply { getHiddenCalled = true }
+
+        override fun setHidden(hidden: Boolean) {
+            setHiddenCalled = true
+        }
+
+        override fun getActivitySpaceAlpha(): Float =
+            1.0f.apply { getActivitySpaceAlphaCalled = true }
+
+        override fun setScale(scale: Vector3) {
+            setScaleCalled = true
+        }
+
+        override fun getScale(): Vector3 {
+            getScaleCalled = true
+            return Vector3()
+        }
+
+        override fun getWorldSpaceScale(): Vector3 {
+            getWorldSpaceScaleCalled = true
+            return Vector3()
+        }
+
+        override fun getActivitySpaceScale(): Vector3 {
+            getActivitySpaceScaleCalled = true
+            return Vector3()
+        }
+
+        override fun addInputEventListener(
+            executor: Executor,
+            consumer: JxrPlatformAdapter.InputEventListener,
+        ) {}
+
+        override fun removeInputEventListener(consumer: JxrPlatformAdapter.InputEventListener) {}
+
+        override fun getParent(): JxrPlatformAdapter.Entity? {
+            return null
+        }
+
+        override fun setParent(parent: JxrPlatformAdapter.Entity?) {}
+
+        override fun addChild(child: JxrPlatformAdapter.Entity) {}
+
+        override fun addChildren(children: List<JxrPlatformAdapter.Entity>) {}
+
+        override fun getChildren(): List<JxrPlatformAdapter.Entity> {
+            return emptyList()
+        }
+
+        override fun setContentDescription(text: String) {}
+
+        override fun dispose() {}
+
+        override fun addComponent(component: JxrPlatformAdapter.Component): Boolean = false
+
+        override fun removeComponent(component: JxrPlatformAdapter.Component) {}
+
+        override fun removeAllComponents() {}
+
+        override fun getBounds(): JxrPlatformAdapter.Dimensions =
+            JxrPlatformAdapter.Dimensions(1f, 1f, 1f).apply { getBoundsCalled = true }
+
+        override fun addOnBoundsChangedListener(
+            listener: JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener
+        ) {
+            boundsChangedListener = listener
+        }
+
+        override fun removeOnBoundsChangedListener(
+            listener: JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener
+        ) {
+            boundsChangedListener = null
+        }
+
+        fun sendBoundsChanged(dimensions: JxrPlatformAdapter.Dimensions) {
+            boundsChangedListener?.onBoundsChanged(dimensions)
+        }
+
+        override fun setOnSpaceUpdatedListener(
+            listener: JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener?,
+            executor: Executor?,
+        ) {}
+    }
+
+    private val testActivitySpace = TestRtActivitySpace()
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(testActivitySpace)
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.loadGltfByAssetNameSplitEngine(Mockito.anyString()))
+            .thenReturn(Futures.immediateFuture(mock()))
+        whenever(mockRuntime.createGltfEntity(any(), any(), any()))
+            .thenReturn(mockGltfModelEntityImpl)
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockPanelEntityImpl)
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), any()))
+            .thenReturn(mockAnchorEntityImpl)
+        whenever(mockAnchorEntityImpl.state)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.State.UNANCHORED)
+        whenever(mockAnchorEntityImpl.persistState)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_NOT_REQUESTED)
+        whenever(mockRuntime.createActivityPanelEntity(any(), any(), any(), any(), any()))
+            .thenReturn(mockActivityPanelEntity)
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockContentlessEntity)
+        whenever(mockRuntime.getMainPanelEntity()).thenReturn(mockPanelEntityImpl)
+        session = Session.create(activity, mockRuntime)
+        activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+        gltfModel = session.createGltfResourceAsync("test.glb").get()
+        gltfModelEntity = GltfModelEntity.create(mockRuntime, entityManager, gltfModel)
+        panelEntity =
+            PanelEntity.create(
+                mockRuntime,
+                entityManager = entityManager,
+                TextView(activity),
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test",
+                activity,
+            )
+        anchorEntity =
+            AnchorEntity.create(
+                mockRuntime,
+                entityManager,
+                Dimensions(),
+                PlaneType.ANY,
+                PlaneSemantic.ANY,
+                10.seconds.toJavaDuration(),
+            )
+        activityPanelEntity =
+            ActivityPanelEntity.create(
+                mockRuntime,
+                entityManager = entityManager,
+                PixelDimensions(640, 480),
+                "test",
+                activity,
+            )
+        contentlessEntity = ContentlessEntity.create(mockRuntime, entityManager, "test")
+    }
+
+    @Test
+    fun anchorEntityCreateWithNullTimeout_passesNullToImpl() {
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), any()))
+            .thenReturn(mockAnchorEntityImpl)
+        anchorEntity =
+            AnchorEntity.create(
+                mockRuntime,
+                entityManager,
+                Dimensions(),
+                PlaneType.ANY,
+                PlaneSemantic.ANY,
+            )
+
+        assertThat(anchorEntity).isNotNull()
+    }
+
+    @Test
+    fun allEntitySetParent_callsRuntimeEntityImplSetParent() {
+        panelEntity.setParent(activitySpace)
+        gltfModelEntity.setParent(activitySpace)
+        anchorEntity.setParent(activitySpace)
+        activityPanelEntity.setParent(activitySpace)
+
+        verify(mockPanelEntityImpl).parent = session.runtime.activitySpace
+        verify(mockGltfModelEntityImpl).parent = session.runtime.activitySpace
+        verify(mockAnchorEntityImpl).parent = session.runtime.activitySpace
+        verify(mockActivityPanelEntity).parent = session.runtime.activitySpace
+    }
+
+    @Test
+    fun allEntitySetParentNull_SetsNullParent() {
+        panelEntity.setParent(null)
+        gltfModelEntity.setParent(null)
+        anchorEntity.setParent(null)
+        activityPanelEntity.setParent(null)
+
+        verify(mockPanelEntityImpl).parent = null
+        verify(mockGltfModelEntityImpl).parent = null
+        verify(mockAnchorEntityImpl).parent = null
+        verify(mockActivityPanelEntity).parent = null
+    }
+
+    @Test
+    fun allEntityGetParent_callsRuntimeEntityImplGetParent() {
+        val rtActivitySpace = session.runtime.activitySpace
+        whenever(mockActivityPanelEntity.parent).thenReturn(rtActivitySpace)
+        whenever(mockPanelEntityImpl.parent).thenReturn(mockActivityPanelEntity)
+        whenever(mockGltfModelEntityImpl.parent).thenReturn(mockPanelEntityImpl)
+        whenever(mockContentlessEntity.parent).thenReturn(mockGltfModelEntityImpl)
+        whenever(mockAnchorEntityImpl.parent).thenReturn(mockContentlessEntity)
+
+        assertThat(activityPanelEntity.getParent()).isEqualTo(activitySpace)
+        assertThat(panelEntity.getParent()).isEqualTo(activityPanelEntity)
+        assertThat(gltfModelEntity.getParent()).isEqualTo(panelEntity)
+        assertThat(contentlessEntity.getParent()).isEqualTo(gltfModelEntity)
+        assertThat(anchorEntity.getParent()).isEqualTo(contentlessEntity)
+
+        verify(mockActivityPanelEntity).parent
+        verify(mockPanelEntityImpl).parent
+        verify(mockGltfModelEntityImpl).parent
+        verify(mockContentlessEntity).parent
+        verify(mockAnchorEntityImpl).parent
+    }
+
+    @Test
+    fun allEntityGetParent_nullParent_callsRuntimeEntityImplGetParent() {
+        whenever(mockActivityPanelEntity.parent).thenReturn(null)
+        whenever(mockPanelEntityImpl.parent).thenReturn(null)
+        whenever(mockGltfModelEntityImpl.parent).thenReturn(null)
+        whenever(mockContentlessEntity.parent).thenReturn(null)
+        whenever(mockAnchorEntityImpl.parent).thenReturn(null)
+
+        assertThat(activityPanelEntity.getParent()).isEqualTo(null)
+        assertThat(panelEntity.getParent()).isEqualTo(null)
+        assertThat(gltfModelEntity.getParent()).isEqualTo(null)
+        assertThat(contentlessEntity.getParent()).isEqualTo(null)
+        assertThat(anchorEntity.getParent()).isEqualTo(null)
+
+        verify(mockActivityPanelEntity).parent
+        verify(mockPanelEntityImpl).parent
+        verify(mockGltfModelEntityImpl).parent
+        verify(mockContentlessEntity).parent
+        verify(mockAnchorEntityImpl).parent
+    }
+
+    @Test
+    fun allEntityAddChild_callsRuntimeEntityImplAddChild() {
+        anchorEntity.addChild(panelEntity)
+        panelEntity.addChild(gltfModelEntity)
+        gltfModelEntity.addChild(activityPanelEntity)
+
+        verify(mockAnchorEntityImpl).addChild(mockPanelEntityImpl)
+        verify(mockPanelEntityImpl).addChild(mockGltfModelEntityImpl)
+        verify(mockGltfModelEntityImpl).addChild(mockActivityPanelEntity)
+    }
+
+    @Test
+    fun allEntitySetPose_callsRuntimeEntityImplSetPose() {
+        val pose = Pose.Identity
+
+        panelEntity.setPose(pose)
+        gltfModelEntity.setPose(pose)
+        anchorEntity.setPose(pose)
+        activityPanelEntity.setPose(pose)
+
+        verify(mockPanelEntityImpl).setPose(any())
+        verify(mockGltfModelEntityImpl).setPose(any())
+        verify(mockAnchorEntityImpl).setPose(any())
+        verify(mockActivityPanelEntity).setPose(any())
+    }
+
+    @Test
+    fun allEntityGetPose_callsRuntimeEntityImplGetPose() {
+        whenever(mockPanelEntityImpl.pose).thenReturn(Pose())
+        whenever(mockGltfModelEntityImpl.pose).thenReturn(Pose())
+        whenever(mockAnchorEntityImpl.pose).thenReturn(Pose())
+        whenever(mockActivityPanelEntity.pose).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(panelEntity.getPose()).isEqualTo(pose)
+        assertThat(gltfModelEntity.getPose()).isEqualTo(pose)
+        assertThat(anchorEntity.getPose()).isEqualTo(pose)
+        assertThat(activityPanelEntity.getPose()).isEqualTo(pose)
+
+        verify(mockPanelEntityImpl).getPose()
+        verify(mockGltfModelEntityImpl).getPose()
+        verify(mockAnchorEntityImpl).getPose()
+        verify(mockActivityPanelEntity).getPose()
+    }
+
+    @Test
+    fun allEntityGetActivitySpacePose_callsRuntimeEntityImplGetActivitySpacePose() {
+        whenever(mockPanelEntityImpl.activitySpacePose).thenReturn(Pose())
+        whenever(mockGltfModelEntityImpl.activitySpacePose).thenReturn(Pose())
+        whenever(mockAnchorEntityImpl.activitySpacePose).thenReturn(Pose())
+        whenever(mockActivityPanelEntity.activitySpacePose).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(panelEntity.getActivitySpacePose()).isEqualTo(pose)
+        assertThat(gltfModelEntity.getActivitySpacePose()).isEqualTo(pose)
+        assertThat(anchorEntity.getActivitySpacePose()).isEqualTo(pose)
+        assertThat(activityPanelEntity.getActivitySpacePose()).isEqualTo(pose)
+
+        verify(mockPanelEntityImpl).activitySpacePose
+        verify(mockGltfModelEntityImpl).activitySpacePose
+        verify(mockAnchorEntityImpl).activitySpacePose
+        verify(mockActivityPanelEntity).activitySpacePose
+    }
+
+    @Test
+    fun allEntitySetAlpha_callsRuntimeEntityImplSetAlpha() {
+        val alpha = 0.1f
+
+        panelEntity.setAlpha(alpha)
+        gltfModelEntity.setAlpha(alpha)
+        anchorEntity.setAlpha(alpha)
+        activityPanelEntity.setAlpha(alpha)
+        contentlessEntity.setAlpha(alpha)
+        activitySpace.setAlpha(alpha)
+
+        verify(mockPanelEntityImpl).setAlpha(any())
+        verify(mockGltfModelEntityImpl).setAlpha(any())
+        verify(mockAnchorEntityImpl).setAlpha(any())
+        verify(mockActivityPanelEntity).setAlpha(any())
+        verify(mockContentlessEntity).setAlpha(any())
+        assertThat(testActivitySpace.setAlphaCalled).isTrue()
+    }
+
+    @Test
+    fun allEntityGetAlpha_callsRuntimeEntityImplGetAlpha() {
+        whenever(mockPanelEntityImpl.getAlpha()).thenReturn(0.1f)
+        whenever(mockGltfModelEntityImpl.getAlpha()).thenReturn(0.1f)
+        whenever(mockAnchorEntityImpl.getAlpha()).thenReturn(0.1f)
+        whenever(mockActivityPanelEntity.getAlpha()).thenReturn(0.1f)
+
+        assertThat(panelEntity.getAlpha()).isEqualTo(0.1f)
+        assertThat(gltfModelEntity.getAlpha()).isEqualTo(0.1f)
+        assertThat(anchorEntity.getAlpha()).isEqualTo(0.1f)
+        assertThat(activityPanelEntity.getAlpha()).isEqualTo(0.1f)
+
+        assertThat(activitySpace.getAlpha()).isEqualTo(1.0f)
+        assertThat(testActivitySpace.getAlphaCalled).isTrue()
+
+        verify(mockPanelEntityImpl).getAlpha()
+        verify(mockGltfModelEntityImpl).getAlpha()
+        verify(mockAnchorEntityImpl).getAlpha()
+        verify(mockActivityPanelEntity).getAlpha()
+    }
+
+    @Test
+    fun allEntitySetHiddened_callsRuntimeEntityImplSetHidden() {
+        panelEntity.setHidden(true)
+        gltfModelEntity.setHidden(true)
+        anchorEntity.setHidden(true)
+        activityPanelEntity.setHidden(false)
+        contentlessEntity.setHidden(false)
+        activitySpace.setHidden(false)
+
+        verify(mockPanelEntityImpl).setHidden(true)
+        verify(mockGltfModelEntityImpl).setHidden(true)
+        verify(mockAnchorEntityImpl).setHidden(true)
+        verify(mockActivityPanelEntity).setHidden(false)
+        verify(mockContentlessEntity).setHidden(false)
+        assertThat(testActivitySpace.setHiddenCalled).isTrue()
+    }
+
+    @Test
+    fun allEntityGetActivitySpaceAlpha_callsRuntimeEntityImplGetActivitySpaceAlpha() {
+        whenever(mockPanelEntityImpl.activitySpaceAlpha).thenReturn(0.1f)
+        whenever(mockGltfModelEntityImpl.activitySpaceAlpha).thenReturn(0.1f)
+        whenever(mockAnchorEntityImpl.activitySpaceAlpha).thenReturn(0.1f)
+        whenever(mockActivityPanelEntity.activitySpaceAlpha).thenReturn(0.1f)
+
+        assertThat(panelEntity.getActivitySpaceAlpha()).isEqualTo(0.1f)
+        assertThat(gltfModelEntity.getActivitySpaceAlpha()).isEqualTo(0.1f)
+        assertThat(anchorEntity.getActivitySpaceAlpha()).isEqualTo(0.1f)
+        assertThat(activityPanelEntity.getActivitySpaceAlpha()).isEqualTo(0.1f)
+
+        assertThat(activitySpace.getActivitySpaceAlpha()).isEqualTo(1.0f)
+        assertThat(testActivitySpace.getActivitySpaceAlphaCalled).isTrue()
+
+        verify(mockPanelEntityImpl).activitySpaceAlpha
+        verify(mockGltfModelEntityImpl).activitySpaceAlpha
+        verify(mockAnchorEntityImpl).activitySpaceAlpha
+        verify(mockActivityPanelEntity).activitySpaceAlpha
+    }
+
+    @Test
+    fun allEntitySetScale_callsRuntimeEntityImplSetScale() {
+        val scale = 0.1f
+
+        panelEntity.setScale(scale)
+        gltfModelEntity.setScale(scale)
+        // Note that in production we expect this to raise an exception, but that should be handled
+        // by the runtime Entity.
+        anchorEntity.setScale(scale)
+        activityPanelEntity.setScale(scale)
+        contentlessEntity.setScale(scale)
+        // Note that in production we expect this to do nothing.
+        activitySpace.setScale(scale)
+
+        verify(mockPanelEntityImpl).setScale(any())
+        verify(mockGltfModelEntityImpl).setScale(any())
+        verify(mockAnchorEntityImpl).setScale(any())
+        verify(mockActivityPanelEntity).setScale(any())
+        verify(mockContentlessEntity).setScale(any())
+        assertThat(testActivitySpace.setScaleCalled).isTrue()
+    }
+
+    @Test
+    fun allEntityGetScale_callsRuntimeEntityImplGetScale() {
+        val runtimeScale = Vector3(0.1f, 0.1f, 0.1f)
+        val sdkScale = 0.1f
+
+        whenever(mockPanelEntityImpl.getScale()).thenReturn(runtimeScale)
+        whenever(mockGltfModelEntityImpl.getScale()).thenReturn(runtimeScale)
+        whenever(mockAnchorEntityImpl.getScale()).thenReturn(runtimeScale)
+        whenever(mockActivityPanelEntity.getScale()).thenReturn(runtimeScale)
+
+        assertThat(panelEntity.getScale()).isEqualTo(sdkScale)
+        assertThat(gltfModelEntity.getScale()).isEqualTo(sdkScale)
+        assertThat(anchorEntity.getScale()).isEqualTo(sdkScale)
+        assertThat(activityPanelEntity.getScale()).isEqualTo(sdkScale)
+
+        // This is unrealistic, but we want to make sure the SDK delegates to the runtimeImpl.
+        assertThat(activitySpace.getScale()).isEqualTo(0f)
+        assertThat(testActivitySpace.getScaleCalled).isTrue()
+
+        verify(mockPanelEntityImpl).getScale()
+        verify(mockGltfModelEntityImpl).getScale()
+        verify(mockAnchorEntityImpl).getScale()
+        verify(mockActivityPanelEntity).getScale()
+    }
+
+    @Test
+    fun allEntityGetWorldSpaceScale_callsRuntimeEntityImplGetWorldSpaceScale() {
+        val runtimeScale = Vector3(0.1f, 0.1f, 0.1f)
+        val sdkScale = 0.1f
+
+        whenever(mockPanelEntityImpl.getWorldSpaceScale()).thenReturn(runtimeScale)
+        whenever(mockGltfModelEntityImpl.getWorldSpaceScale()).thenReturn(runtimeScale)
+        whenever(mockAnchorEntityImpl.getWorldSpaceScale()).thenReturn(runtimeScale)
+        whenever(mockActivityPanelEntity.getWorldSpaceScale()).thenReturn(runtimeScale)
+
+        assertThat(panelEntity.getWorldSpaceScale()).isEqualTo(sdkScale)
+        assertThat(gltfModelEntity.getWorldSpaceScale()).isEqualTo(sdkScale)
+        assertThat(anchorEntity.getWorldSpaceScale()).isEqualTo(sdkScale)
+        assertThat(activityPanelEntity.getWorldSpaceScale()).isEqualTo(sdkScale)
+
+        // This is unrealistic, but we want to make sure the SDK delegates to the runtimeImpl.
+        assertThat(activitySpace.getWorldSpaceScale()).isEqualTo(0f)
+        assertThat(testActivitySpace.getWorldSpaceScaleCalled).isTrue()
+
+        verify(mockPanelEntityImpl).getWorldSpaceScale()
+        verify(mockGltfModelEntityImpl).getWorldSpaceScale()
+        verify(mockAnchorEntityImpl).getWorldSpaceScale()
+        verify(mockActivityPanelEntity).getWorldSpaceScale()
+    }
+
+    @Test
+    fun allEntityTransformPoseTo_callsRuntimeEntityImplTransformPoseTo() {
+        whenever(mockPanelEntityImpl.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockGltfModelEntityImpl.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockAnchorEntityImpl.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockActivityPanelEntity.transformPoseTo(any(), any())).thenReturn(Pose())
+        whenever(mockContentlessEntity.transformPoseTo(any(), any())).thenReturn(Pose())
+        val pose = Pose.Identity
+
+        assertThat(panelEntity.transformPoseTo(pose, panelEntity)).isEqualTo(pose)
+        assertThat(gltfModelEntity.transformPoseTo(pose, panelEntity)).isEqualTo(pose)
+        assertThat(anchorEntity.transformPoseTo(pose, panelEntity)).isEqualTo(pose)
+        assertThat(activityPanelEntity.transformPoseTo(pose, panelEntity)).isEqualTo(pose)
+        assertThat(contentlessEntity.transformPoseTo(pose, panelEntity)).isEqualTo(pose)
+
+        verify(mockPanelEntityImpl).transformPoseTo(any(), any())
+        verify(mockGltfModelEntityImpl).transformPoseTo(any(), any())
+        verify(mockAnchorEntityImpl).transformPoseTo(any(), any())
+        verify(mockActivityPanelEntity).transformPoseTo(any(), any())
+        verify(mockContentlessEntity).transformPoseTo(any(), any())
+    }
+
+    @Test
+    fun allEntitySetSize_callsRuntimeEntityImplSetSize() {
+        val dimensions = Dimensions(0.3f, 0.2f, 0.1f)
+
+        panelEntity.setSize(dimensions)
+        gltfModelEntity.setSize(dimensions)
+        anchorEntity.setSize(dimensions)
+        activityPanelEntity.setSize(dimensions)
+
+        verify(mockPanelEntityImpl).setSize(any())
+        verify(mockGltfModelEntityImpl).setSize(any())
+        verify(mockAnchorEntityImpl).setSize(any())
+        verify(mockActivityPanelEntity).setSize(any())
+    }
+
+    @Test
+    fun allNonPanelEntityGetSize_returnsSize() {
+        val dimensions = Dimensions(0.3f, 0.2f, 0.1f)
+
+        gltfModelEntity.setSize(dimensions)
+        anchorEntity.setSize(dimensions)
+
+        assertThat(gltfModelEntity.getSize()).isEqualTo(dimensions)
+        assertThat(anchorEntity.getSize()).isEqualTo(dimensions)
+    }
+
+    @Test
+    fun allPanelEntityGetSize_callsRuntimeEntityImplGetSize() {
+        val dimensions = JxrPlatformAdapter.Dimensions(320.0f, 240.0f, 0.0f)
+        val expectedDimensions = dimensions.toDimensions()
+
+        whenever(mockPanelEntityImpl.getSize()).thenReturn(dimensions)
+        whenever(mockActivityPanelEntity.getSize()).thenReturn(dimensions)
+
+        assertThat(panelEntity.getSize()).isEqualTo(expectedDimensions)
+        assertThat(activityPanelEntity.getSize()).isEqualTo(expectedDimensions)
+
+        verify(mockPanelEntityImpl).getSize()
+        verify(mockActivityPanelEntity).getSize()
+    }
+
+    @Test
+    fun allPanelEntityGetPixelDensity_callsRuntimeEntityImplGetPixelDensity() {
+        val pixelDensity = Vector3(14.0f, 14.0f, 14.0f)
+        val expectedPixelDensity = pixelDensity
+        whenever(mockPanelEntityImpl.getPixelDensity()).thenReturn(pixelDensity)
+        whenever(mockActivityPanelEntity.getPixelDensity()).thenReturn(pixelDensity)
+
+        assertThat(panelEntity.getPixelDensity()).isEqualTo(expectedPixelDensity)
+        assertThat(activityPanelEntity.getPixelDensity()).isEqualTo(expectedPixelDensity)
+
+        verify(mockPanelEntityImpl).getPixelDensity()
+        verify(mockActivityPanelEntity).getPixelDensity()
+    }
+
+    @Test
+    fun allPanelEntitySetPixelDimensions_callsRuntimeEntityImplSetPixelDimensions() {
+        val dimensions = PixelDimensions(320, 240)
+        panelEntity.setPixelDimensions(dimensions)
+        activityPanelEntity.setPixelDimensions(dimensions)
+
+        verify(mockPanelEntityImpl).setPixelDimensions(any())
+        verify(mockActivityPanelEntity).setPixelDimensions(any())
+    }
+
+    @Test
+    fun allPanelEntityGetPixelDimensions_callsRuntimeEntityImplGetPixelDimensions() {
+        val pixelDimensions = JxrPlatformAdapter.PixelDimensions(320, 240)
+        val expectedPixelDimensions = pixelDimensions.toPixelDimensions()
+
+        whenever(mockPanelEntityImpl.getPixelDimensions()).thenReturn(pixelDimensions)
+        whenever(mockActivityPanelEntity.getPixelDimensions()).thenReturn(pixelDimensions)
+
+        assertThat(panelEntity.getPixelDimensions()).isEqualTo(expectedPixelDimensions)
+        assertThat(activityPanelEntity.getPixelDimensions()).isEqualTo(expectedPixelDimensions)
+
+        verify(mockPanelEntityImpl).getPixelDimensions()
+        verify(mockActivityPanelEntity).getPixelDimensions()
+    }
+
+    @Test
+    fun allEntityDispose_callsRuntimeEntityImplDispose() {
+        gltfModelEntity.dispose()
+        panelEntity.dispose()
+        anchorEntity.dispose()
+        activityPanelEntity.dispose()
+
+        verify(mockGltfModelEntityImpl).dispose()
+        verify(mockPanelEntityImpl).dispose()
+        verify(mockAnchorEntityImpl).dispose()
+        verify(mockActivityPanelEntity).dispose()
+    }
+
+    @Test
+    fun activityPanelEntityLaunchActivity_callsImplLaunchActivity() {
+        val launchIntent = Intent(activity.applicationContext, Activity::class.java)
+        activityPanelEntity.launchActivity(launchIntent, null)
+
+        verify(mockActivityPanelEntity).launchActivity(launchIntent, null)
+    }
+
+    @Test
+    fun activityPanelEntityMoveActivity_callsImplMoveActivity() {
+        activityPanelEntity.moveActivity(activity)
+
+        verify(mockActivityPanelEntity).moveActivity(any())
+    }
+
+    @Test
+    fun activitySpaceGetBounds_callsImplGetBounds() {
+        val activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+        val bounds = activitySpace.getBounds()
+        assertThat(bounds).isNotNull()
+        assertThat(testActivitySpace.getBoundsCalled).isTrue()
+    }
+
+    @Test
+    fun activitySpaceSetBoundsListener_receivesBoundsChangedCallback() {
+        val activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+        var called = false
+        val boundsChangedListener =
+            Consumer<Dimensions> { newBounds ->
+                assertThat(newBounds.width).isEqualTo(0.3f)
+                assertThat(newBounds.height).isEqualTo(0.2f)
+                assertThat(newBounds.depth).isEqualTo(0.1f)
+                called = true
+            }
+
+        activitySpace.addBoundsChangedListener(directExecutor(), boundsChangedListener)
+        testActivitySpace.sendBoundsChanged(JxrPlatformAdapter.Dimensions(0.3f, 0.2f, 0.1f))
+        assertThat(called).isTrue()
+
+        called = false
+        activitySpace.removeBoundsChangedListener(boundsChangedListener)
+        testActivitySpace.sendBoundsChanged(JxrPlatformAdapter.Dimensions(0.5f, 0.5f, 0.5f))
+        assertThat(called).isFalse()
+    }
+
+    @Test
+    fun setOnSpaceUpdatedListener_withNullParams_callsRuntimeSetOnSpaceUpdatedListener() {
+        val mockRtActivitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+        whenever(mockRuntime.activitySpace).thenReturn(mockRtActivitySpace)
+        val activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+
+        activitySpace.setOnSpaceUpdatedListener(null)
+        verify(mockRtActivitySpace).setOnSpaceUpdatedListener(null, null)
+    }
+
+    @Test
+    fun setOnSpaceUpdatedListener_receivesRuntimeSetOnSpaceUpdatedListenerCallbacks() {
+        val mockRtActivitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+        whenever(mockRuntime.activitySpace).thenReturn(mockRtActivitySpace)
+        val activitySpace = ActivitySpace.create(mockRuntime, entityManager)
+
+        var listenerCalled = false
+        val captor = argumentCaptor<JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener>()
+        activitySpace.setOnSpaceUpdatedListener({ listenerCalled = true }, directExecutor())
+        verify(mockRtActivitySpace).setOnSpaceUpdatedListener(captor.capture(), any())
+        captor.firstValue.onSpaceUpdated()
+        assertThat(listenerCalled).isTrue()
+    }
+
+    @Test
+    fun setOnSpaceUpdatedListener_anchorEntity_withNullParams_callsRuntimeSetOnSpaceUpdatedListener() {
+        anchorEntity.setOnSpaceUpdatedListener(null)
+        verify(mockAnchorEntityImpl).setOnSpaceUpdatedListener(null, null)
+    }
+
+    @Test
+    fun setOnSpaceUpdatedListener_anchorEntity_receivesRuntimeSetOnSpaceUpdatedListenerCallbacks() {
+        var listenerCalled = false
+        val captor = argumentCaptor<JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener>()
+        anchorEntity.setOnSpaceUpdatedListener({ listenerCalled = true }, directExecutor())
+
+        verify(mockAnchorEntityImpl).setOnSpaceUpdatedListener(captor.capture(), any())
+        assertThat(listenerCalled).isFalse()
+        captor.firstValue.onSpaceUpdated()
+        assertThat(listenerCalled).isTrue()
+    }
+
+    @Test
+    fun mainPanelEntity_isMainPanelEntity() {
+        val mainPanelEntity = session.mainPanelEntity
+        assertThat(mainPanelEntity.isMainPanelEntity).isTrue()
+    }
+
+    @Test
+    fun mainPanelEntity_isSingleton() {
+        val mainPanelEntity = session.mainPanelEntity
+        val mainPanelEntity2 = session.mainPanelEntity
+
+        assertThat(mainPanelEntity2).isSameInstanceAs(mainPanelEntity)
+        verify(mockRuntime, times(1)).mainPanelEntity
+    }
+
+    @Test
+    fun contentlessEntity_isCreated() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+    }
+
+    @Test
+    fun contentlessEntity_canSetPose() {
+        val entity = session.createEntity("test")
+        val setPose = Pose.Identity
+        entity.setPose(setPose)
+
+        val captor = argumentCaptor<Pose>()
+        verify(mockContentlessEntity).setPose(captor.capture())
+
+        val pose = captor.firstValue
+        assertThat(pose.translation.x).isEqualTo(setPose.translation.x)
+        assertThat(pose.translation.y).isEqualTo(setPose.translation.y)
+        assertThat(pose.translation.z).isEqualTo(setPose.translation.z)
+    }
+
+    @Test
+    fun contentlessEntity_canGetPose() {
+        whenever(mockContentlessEntity.pose).thenReturn(Pose())
+
+        val entity = session.createEntity("test")
+        val pose = Pose.Identity
+
+        assertThat(entity.getPose()).isEqualTo(pose)
+        verify(mockContentlessEntity).getPose()
+    }
+
+    @Test
+    fun contentlessEntity_canGetActivitySpacePose() {
+        whenever(mockContentlessEntity.activitySpacePose).thenReturn(Pose())
+
+        val entity = session.createEntity("test")
+        val pose = Pose.Identity
+
+        assertThat(entity.getActivitySpacePose()).isEqualTo(pose)
+        verify(mockContentlessEntity).activitySpacePose
+    }
+
+    @Test
+    fun allEntity_addComponentInvokesOnAttach() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_addComponentFailsIfOnAttachFails() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(false)
+
+        assertThat(panelEntity.addComponent(component)).isFalse()
+        verify(component).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isFalse()
+        verify(component).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isFalse()
+        verify(component).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isFalse()
+        verify(component).onAttach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_removeComponentInvokesOnDetach() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(activityPanelEntity)
+
+        panelEntity.removeComponent(component)
+        verify(component).onDetach(panelEntity)
+
+        gltfModelEntity.removeComponent(component)
+        verify(component).onDetach(gltfModelEntity)
+
+        anchorEntity.removeComponent(component)
+        verify(component).onDetach(anchorEntity)
+
+        activityPanelEntity.removeComponent(component)
+        verify(component).onDetach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_addingSameComponentTypeAgainSucceeds() {
+        val component1 = mock<Component>()
+        val component2 = mock<Component>()
+        whenever(component1.onAttach(any())).thenReturn(true)
+        whenever(component2.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component1)).isTrue()
+        assertThat(panelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(panelEntity)
+        verify(component2).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component1)).isTrue()
+        assertThat(gltfModelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(gltfModelEntity)
+        verify(component2).onAttach(panelEntity)
+
+        assertThat(anchorEntity.addComponent(component1)).isTrue()
+        assertThat(anchorEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(anchorEntity)
+        verify(component2).onAttach(panelEntity)
+
+        assertThat(activityPanelEntity.addComponent(component1)).isTrue()
+        assertThat(activityPanelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(activityPanelEntity)
+        verify(component2).onAttach(panelEntity)
+    }
+
+    @Test
+    fun allEntity_addDifferentComponentTypesInvokesOnAttachOnAll() {
+        val component1 = mock<Component>()
+        val component2 = mock<FakeComponent>()
+        whenever(component1.onAttach(any())).thenReturn(true)
+        whenever(component2.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component1)).isTrue()
+        assertThat(panelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(panelEntity)
+        verify(component2).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component1)).isTrue()
+        assertThat(gltfModelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(gltfModelEntity)
+        verify(component2).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component1)).isTrue()
+        assertThat(anchorEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(anchorEntity)
+        verify(component2).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component1)).isTrue()
+        assertThat(activityPanelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(activityPanelEntity)
+        verify(component2).onAttach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_removeAllComponentsInvokesOnDetachOnAll() {
+        val component1 = mock<Component>()
+        val component2 = mock<FakeComponent>()
+        whenever(component1.onAttach(any())).thenReturn(true)
+        whenever(component2.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component1)).isTrue()
+        assertThat(panelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(panelEntity)
+        verify(component2).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component1)).isTrue()
+        assertThat(gltfModelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(gltfModelEntity)
+        verify(component2).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component1)).isTrue()
+        assertThat(anchorEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(anchorEntity)
+        verify(component2).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component1)).isTrue()
+        assertThat(activityPanelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(activityPanelEntity)
+        verify(component2).onAttach(activityPanelEntity)
+
+        panelEntity.removeAllComponents()
+        verify(component1).onDetach(panelEntity)
+        verify(component2).onDetach(panelEntity)
+
+        gltfModelEntity.removeAllComponents()
+        verify(component1).onDetach(gltfModelEntity)
+        verify(component2).onDetach(gltfModelEntity)
+
+        anchorEntity.removeAllComponents()
+        verify(component1).onDetach(anchorEntity)
+        verify(component2).onDetach(anchorEntity)
+
+        activityPanelEntity.removeAllComponents()
+        verify(component1).onDetach(activityPanelEntity)
+        verify(component2).onDetach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_addSameComponentMultipleTimesInvokesOnAttachMutipleTimes() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        verify(component, times(2)).onAttach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        verify(component, times(2)).onAttach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        verify(component, times(2)).onAttach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        verify(component, times(2)).onAttach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_removeSameComponentMultipleTimesInvokesOnDetachOnce() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(panelEntity)
+
+        panelEntity.removeComponent(component)
+        panelEntity.removeComponent(component)
+        verify(component).onDetach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(gltfModelEntity)
+
+        gltfModelEntity.removeComponent(component)
+        gltfModelEntity.removeComponent(component)
+        verify(component).onDetach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(anchorEntity)
+
+        anchorEntity.removeComponent(component)
+        anchorEntity.removeComponent(component)
+        verify(component).onDetach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(activityPanelEntity)
+
+        activityPanelEntity.removeComponent(component)
+        activityPanelEntity.removeComponent(component)
+        verify(component).onDetach(activityPanelEntity)
+    }
+
+    @Test
+    fun anchorEntity_canGetDefaultPersistState() {
+        assertThat(anchorEntity.getPersistState())
+            .isEqualTo(AnchorEntity.PersistState.PERSIST_NOT_REQUESTED)
+    }
+
+    @Test
+    fun anchorEntity_createPersistAnchorSuccess() {
+        whenever(mockRuntime.createPersistedAnchorEntity(any(), any()))
+            .thenReturn(mockAnchorEntityImpl)
+        val persistAnchorEntity = AnchorEntity.create(mockRuntime, entityManager, UUID.randomUUID())
+        assertThat(persistAnchorEntity).isNotNull()
+    }
+
+    @Test
+    fun allEntity_disposeRemovesAllComponents() {
+        val component = mock<Component>()
+        whenever(component.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(panelEntity)
+
+        panelEntity.dispose()
+        verify(component).onDetach(panelEntity)
+
+        assertThat(gltfModelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(gltfModelEntity)
+
+        gltfModelEntity.dispose()
+        verify(component).onDetach(gltfModelEntity)
+
+        assertThat(anchorEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(anchorEntity)
+
+        anchorEntity.dispose()
+        verify(component).onDetach(anchorEntity)
+
+        assertThat(activityPanelEntity.addComponent(component)).isTrue()
+        verify(component).onAttach(activityPanelEntity)
+
+        activityPanelEntity.dispose()
+        verify(component).onDetach(activityPanelEntity)
+    }
+
+    @Test
+    fun allEntity_getComponentsReturnsAttachedComponents() {
+        val component1 = mock<Component>()
+        val component2 = mock<FakeComponent>()
+        whenever(component1.onAttach(any())).thenReturn(true)
+        whenever(component2.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component1)).isTrue()
+        assertThat(panelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(panelEntity)
+        verify(component2).onAttach(panelEntity)
+        assertThat(panelEntity.getComponents()).containsExactly(component1, component2)
+
+        assertThat(gltfModelEntity.addComponent(component1)).isTrue()
+        assertThat(gltfModelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(gltfModelEntity)
+        verify(component2).onAttach(gltfModelEntity)
+        assertThat(gltfModelEntity.getComponents()).containsExactly(component1, component2)
+
+        assertThat(anchorEntity.addComponent(component1)).isTrue()
+        assertThat(anchorEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(anchorEntity)
+        verify(component2).onAttach(anchorEntity)
+        assertThat(anchorEntity.getComponents()).containsExactly(component1, component2)
+
+        assertThat(activityPanelEntity.addComponent(component1)).isTrue()
+        assertThat(activityPanelEntity.addComponent(component2)).isTrue()
+        verify(component1).onAttach(activityPanelEntity)
+        verify(component2).onAttach(activityPanelEntity)
+        assertThat(activityPanelEntity.getComponents()).containsExactly(component1, component2)
+    }
+
+    @Test
+    fun getComponentsOfType_returnsAttachedComponents() {
+        val component1 = mock<Component>()
+        val component2 = mock<FakeComponent>()
+        val component3 = mock<SubtypeFakeComponent>()
+        whenever(component1.onAttach(any())).thenReturn(true)
+        whenever(component2.onAttach(any())).thenReturn(true)
+        whenever(component3.onAttach(any())).thenReturn(true)
+
+        assertThat(panelEntity.addComponent(component1)).isTrue()
+        assertThat(panelEntity.addComponent(component2)).isTrue()
+        assertThat(panelEntity.addComponent(component3)).isTrue()
+        verify(component1).onAttach(panelEntity)
+        verify(component2).onAttach(panelEntity)
+        verify(component3).onAttach(panelEntity)
+        assertThat(panelEntity.getComponentsOfType(FakeComponent::class.java))
+            .containsExactly(component2, component3)
+    }
+
+    @Test
+    fun setCornerRadius() {
+        val radius = 2.0f
+        panelEntity.setCornerRadius(radius)
+        verify(mockPanelEntityImpl).cornerRadius = radius
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/InteractableComponentTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/InteractableComponentTest.kt
new file mode 100644
index 0000000..03bcec5
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/InteractableComponentTest.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class InteractableComponentTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private lateinit var session: Session
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val entity by lazy { session.createEntity("test") }
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(mock())
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.getMainPanelEntity()).thenReturn(mock())
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockContentlessEntity)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun addInteractableComponent_addsRuntimeInteractableComponent() {
+        assertThat(entity).isNotNull()
+
+        whenever(mockRuntime.createInteractableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val mockListener = mock<InputEventListener>()
+        val executor = directExecutor()
+        val interactableComponent = session.createInteractableComponent(executor, mockListener)
+
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+        verify(mockRuntime).createInteractableComponent(any(), anyOrNull())
+        verify(mockContentlessEntity).addComponent(any())
+    }
+
+    @Test
+    fun removeInteractableComponent_removesRuntimeInteractableComponent() {
+        assertThat(entity).isNotNull()
+
+        whenever(mockRuntime.createInteractableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val mockListener = mock<InputEventListener>()
+        val executor = directExecutor()
+        val interactableComponent = session.createInteractableComponent(executor, mockListener)
+
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+
+        entity.removeComponent(interactableComponent)
+        verify(mockContentlessEntity).removeComponent(any())
+    }
+
+    @Test
+    fun interactableComponent_canAttachOnlyOnce() {
+        val entity2 = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        whenever(mockRuntime.createInteractableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val mockListener = mock<InputEventListener>()
+        val executor = directExecutor()
+        val interactableComponent = session.createInteractableComponent(executor, mockListener)
+
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+        assertThat(entity2.addComponent(interactableComponent)).isFalse()
+    }
+
+    @Test
+    fun interactableComponent_canAttachAgainAfterDetach() {
+        assertThat(entity).isNotNull()
+
+        whenever(mockRuntime.createInteractableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val mockListener = mock<InputEventListener>()
+        val executor = directExecutor()
+        val interactableComponent = session.createInteractableComponent(executor, mockListener)
+
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+        entity.removeComponent(interactableComponent)
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+    }
+
+    @Test
+    fun interactableComponent_propagatesHitInfoInInputEvents() {
+        val mockRtInteractableComponent = mock<JxrPlatformAdapter.InteractableComponent>()
+        whenever(mockRuntime.createInteractableComponent(any(), any()))
+            .thenReturn(mockRtInteractableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val mockListener = mock<InputEventListener>()
+        val interactableComponent =
+            session.createInteractableComponent(directExecutor(), mockListener)
+        assertThat(entity.addComponent(interactableComponent)).isTrue()
+        val listenerCaptor = argumentCaptor<JxrPlatformAdapter.InputEventListener>()
+        verify(mockRuntime).createInteractableComponent(any(), listenerCaptor.capture())
+        val rtInputEventListener = listenerCaptor.lastValue
+        val rtInputEvent =
+            JxrPlatformAdapter.InputEvent(
+                JxrPlatformAdapter.InputEvent.SOURCE_HANDS,
+                JxrPlatformAdapter.InputEvent.POINTER_TYPE_RIGHT,
+                123456789L,
+                Vector3.Zero,
+                Vector3.One,
+                JxrPlatformAdapter.InputEvent.ACTION_DOWN,
+                JxrPlatformAdapter.InputEvent.HitInfo(
+                    mockContentlessEntity,
+                    Vector3.One,
+                    Matrix4.Identity
+                ),
+                null,
+            )
+        rtInputEventListener.onInputEvent(rtInputEvent)
+        val inputEventCaptor = argumentCaptor<InputEvent>()
+        verify(mockListener).onInputEvent(inputEventCaptor.capture())
+        val inputEvent = inputEventCaptor.lastValue
+        assertThat(inputEvent.source).isEqualTo(InputEvent.SOURCE_HANDS)
+        assertThat(inputEvent.pointerType).isEqualTo(InputEvent.POINTER_TYPE_RIGHT)
+        assertThat(inputEvent.timestamp).isEqualTo(rtInputEvent.timestamp)
+        assertThat(inputEvent.action).isEqualTo(InputEvent.ACTION_DOWN)
+        assertThat(inputEvent.hitInfo).isNotNull()
+        assertThat(inputEvent.hitInfo!!.inputEntity).isEqualTo(entity)
+        assertThat(inputEvent.hitInfo!!.hitPosition).isEqualTo(Vector3.One)
+        assertThat(inputEvent.hitInfo!!.transform).isEqualTo(Matrix4.Identity)
+        assertThat(inputEvent.secondaryHitInfo).isNull()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/MovableComponentTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/MovableComponentTest.kt
new file mode 100644
index 0000000..f5c6a13
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/MovableComponentTest.kt
@@ -0,0 +1,331 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.kotlin.any
+import org.mockito.kotlin.isA
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MovableComponentTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private lateinit var session: Session
+    private val mockActivitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val mockAnchorEntity = mock<JxrPlatformAdapter.AnchorEntity>()
+    private val entityManager = EntityManager()
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(mockActivitySpace)
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.getMainPanelEntity()).thenReturn(mock())
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockContentlessEntity)
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), any()))
+            .thenReturn(mockAnchorEntity)
+        whenever(mockAnchorEntity.state)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.State.UNANCHORED)
+        whenever(mockAnchorEntity.persistState)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_NOT_REQUESTED)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun addMovableComponent_addsRuntimeMovableComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockAnchorPlacement = mock<JxrPlatformAdapter.AnchorPlacement>()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        whenever(
+                mockRuntime.createAnchorPlacementForPlanes(
+                    setOf(JxrPlatformAdapter.PlaneType.HORIZONTAL),
+                    setOf(JxrPlatformAdapter.PlaneSemantic.WALL),
+                )
+            )
+            .thenReturn(mockAnchorPlacement)
+
+        val anchorPlacement =
+            AnchorPlacement.createForPlanes(setOf(PlaneType.HORIZONTAL), setOf(PlaneSemantic.WALL))
+
+        val movableComponent =
+            session.createMovableComponent(
+                systemMovable = false,
+                scaleInZ = false,
+                anchorPlacement = setOf(anchorPlacement),
+                shouldDisposeParentAnchor = false,
+            )
+
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        verify(mockRuntime).createMovableComponent(false, false, setOf(mockAnchorPlacement), false)
+        verify(mockContentlessEntity).addComponent(any())
+    }
+
+    @Test
+    fun addMovableComponentDefaultArguments_addsRuntimeMovableComponentWithDefaults() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        verify(mockRuntime)
+            .createMovableComponent(
+                /*systemMovable=*/ true,
+                /*scaleInZ=*/ true,
+                /*anchorPlacement=*/ emptySet(),
+                /*shouldDisposeParentAnchor=*/ true,
+            )
+        verify(mockContentlessEntity).addComponent(any())
+    }
+
+    @Test
+    fun removeMovableComponent_removesRuntimeMovableComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+
+        entity.removeComponent(movableComponent)
+        verify(mockContentlessEntity).removeComponent(any())
+    }
+
+    @Test
+    fun movableComponent_canAttachOnlyOnce() {
+        val entity = session.createEntity("test")
+        val entity2 = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        assertThat(entity2.addComponent(movableComponent)).isFalse()
+    }
+
+    @Test
+    fun movableComponent_setSizeInvokesRuntimeMovableComponentSetSize() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        val mockRtMovableComponent = mock<JxrPlatformAdapter.MovableComponent>()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any()))
+            .thenReturn(mockRtMovableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+
+        val testSize = Dimensions(2f, 2f, 0f)
+        movableComponent.size = testSize
+
+        assertThat(movableComponent.size).isEqualTo(testSize)
+        verify(mockRtMovableComponent).setSize(any())
+    }
+
+    @Test
+    fun movableComponent_addMoveListenerInvokesRuntimeMovableComponentAddMoveEventListener() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtMovableComponent = mock<JxrPlatformAdapter.MovableComponent>()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any()))
+            .thenReturn(mockRtMovableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        val mockMoveListener = mock<MoveListener>()
+        movableComponent.addMoveListener(directExecutor(), mockMoveListener)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.MoveEventListener::class.java)
+        verify(mockRtMovableComponent).addMoveEventListener(any(), captor.capture())
+        val rtMoveEventListener = captor.value
+        var rtMoveEvent =
+            JxrPlatformAdapter.MoveEvent(
+                MoveEvent.MOVE_STATE_START,
+                JxrPlatformAdapter.Ray(Vector3(0f, 0f, 0f), Vector3(1f, 1f, 1f)),
+                JxrPlatformAdapter.Ray(Vector3(1f, 1f, 1f), Vector3(2f, 2f, 2f)),
+                Pose(),
+                Pose(),
+                Vector3(1f, 1f, 1f),
+                Vector3(1f, 1f, 1f),
+                mockActivitySpace,
+                /* updatedParent= */ null,
+                /* disposedEntity= */ null,
+            )
+        rtMoveEventListener.onMoveEvent(rtMoveEvent)
+
+        verify(mockMoveListener).onMoveStart(any(), any(), any(), any(), any())
+
+        rtMoveEvent =
+            JxrPlatformAdapter.MoveEvent(
+                MoveEvent.MOVE_STATE_ONGOING,
+                JxrPlatformAdapter.Ray(Vector3(0f, 0f, 0f), Vector3(1f, 1f, 1f)),
+                JxrPlatformAdapter.Ray(Vector3(1f, 1f, 1f), Vector3(2f, 2f, 2f)),
+                Pose(),
+                Pose(),
+                Vector3(1f, 1f, 1f),
+                Vector3(1f, 1f, 1f),
+                mockActivitySpace,
+                /* updatedParent= */ null,
+                /* disposedEntity= */ null,
+            )
+        rtMoveEventListener.onMoveEvent(rtMoveEvent)
+
+        verify(mockMoveListener).onMoveUpdate(any(), any(), any(), any())
+
+        rtMoveEvent =
+            JxrPlatformAdapter.MoveEvent(
+                MoveEvent.MOVE_STATE_END,
+                JxrPlatformAdapter.Ray(Vector3(0f, 0f, 0f), Vector3(1f, 1f, 1f)),
+                JxrPlatformAdapter.Ray(Vector3(1f, 1f, 1f), Vector3(2f, 2f, 2f)),
+                Pose(),
+                Pose(),
+                Vector3(1f, 1f, 1f),
+                Vector3(1f, 1f, 1f),
+                mockActivitySpace,
+                mockAnchorEntity,
+                /* disposedEntity= */ null,
+            )
+        rtMoveEventListener.onMoveEvent(rtMoveEvent)
+
+        verify(mockMoveListener).onMoveEnd(any(), any(), any(), any(), isA<AnchorEntity>())
+    }
+
+    @Test
+    fun movableComponent_addMultipleMoveEventListenersInvokesAllListeners() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtMovableComponent = mock<JxrPlatformAdapter.MovableComponent>()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any()))
+            .thenReturn(mockRtMovableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        val mockMoveListener = mock<MoveListener>()
+        movableComponent.addMoveListener(directExecutor(), mockMoveListener)
+        val mockMoveListener2 = mock<MoveListener>()
+        movableComponent.addMoveListener(directExecutor(), mockMoveListener2)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.MoveEventListener::class.java)
+        verify(mockRtMovableComponent, times(2)).addMoveEventListener(any(), captor.capture())
+        val rtMoveEventListener1 = captor.allValues[0]
+        val rtMoveEventListener2 = captor.allValues[1]
+        val rtMoveEvent =
+            JxrPlatformAdapter.MoveEvent(
+                MoveEvent.MOVE_STATE_START,
+                JxrPlatformAdapter.Ray(Vector3(0f, 0f, 0f), Vector3(1f, 1f, 1f)),
+                JxrPlatformAdapter.Ray(Vector3(1f, 1f, 1f), Vector3(2f, 2f, 2f)),
+                Pose(),
+                Pose(),
+                Vector3(1f, 1f, 1f),
+                Vector3(1f, 1f, 1f),
+                mockActivitySpace,
+                /* updatedParent= */ null,
+                /* disposedEntity= */ null,
+            )
+
+        rtMoveEventListener1.onMoveEvent(rtMoveEvent)
+        rtMoveEventListener2.onMoveEvent(rtMoveEvent)
+
+        verify(mockMoveListener).onMoveStart(any(), any(), any(), any(), any())
+        verify(mockMoveListener2).onMoveStart(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun movableComponent_removeMoveEventListenerInvokesRuntimeRemoveMoveEventListener() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtMovableComponent = mock<JxrPlatformAdapter.MovableComponent>()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any()))
+            .thenReturn(mockRtMovableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        val mockMoveListener = mock<MoveListener>()
+        movableComponent.addMoveListener(directExecutor(), mockMoveListener)
+        val mockMoveListener2 = mock<MoveListener>()
+        movableComponent.addMoveListener(directExecutor(), mockMoveListener2)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.MoveEventListener::class.java)
+        verify(mockRtMovableComponent, times(2)).addMoveEventListener(any(), captor.capture())
+        val rtMoveEventListener1 = captor.allValues[0]
+        val rtMoveEventListener2 = captor.allValues[1]
+        val rtMoveEvent =
+            JxrPlatformAdapter.MoveEvent(
+                MoveEvent.MOVE_STATE_START,
+                JxrPlatformAdapter.Ray(Vector3(0f, 0f, 0f), Vector3(1f, 1f, 1f)),
+                JxrPlatformAdapter.Ray(Vector3(1f, 1f, 1f), Vector3(2f, 2f, 2f)),
+                Pose(),
+                Pose(),
+                Vector3(1f, 1f, 1f),
+                Vector3(1f, 1f, 1f),
+                mockActivitySpace,
+                /* updatedParent= */ null,
+                /* disposedEntity= */ null,
+            )
+
+        rtMoveEventListener1.onMoveEvent(rtMoveEvent)
+        rtMoveEventListener2.onMoveEvent(rtMoveEvent)
+
+        verify(mockMoveListener).onMoveStart(any(), any(), any(), any(), any())
+        verify(mockMoveListener2).onMoveStart(any(), any(), any(), any(), any())
+
+        movableComponent.removeMoveListener(mockMoveListener)
+        verify(mockRtMovableComponent).removeMoveEventListener(rtMoveEventListener1)
+
+        rtMoveEventListener2.onMoveEvent(rtMoveEvent)
+        // The first listener, which we removed, should not be called again.
+        verify(mockMoveListener, times(1)).onMoveStart(any(), any(), any(), any(), any())
+        verify(mockMoveListener2, times(2)).onMoveStart(any(), any(), any(), any(), any())
+
+        movableComponent.removeMoveListener(mockMoveListener2)
+        verify(mockRtMovableComponent).removeMoveEventListener(rtMoveEventListener2)
+    }
+
+    @Test
+    fun movablecomponent_canAttachAgainAfterDetach() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val movableComponent = session.createMovableComponent()
+
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+        entity.removeComponent(movableComponent)
+        assertThat(entity.addComponent(movableComponent)).isTrue()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/PointerCaptureComponentTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/PointerCaptureComponentTest.kt
new file mode 100644
index 0000000..6ff171e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/PointerCaptureComponentTest.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class PointerCaptureComponentTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private lateinit var session: Session
+    private val mockRtEntity = mock<JxrPlatformAdapter.Entity>()
+    private val mockRtComponent = mock<JxrPlatformAdapter.PointerCaptureComponent>()
+
+    private val stateListener =
+        object : PointerCaptureComponent.StateListener {
+            var lastState: Int = -1
+
+            override fun onStateChanged(newState: Int) {
+                lastState = newState
+            }
+        }
+
+    private val inputListener =
+        object : InputEventListener {
+            lateinit var lastEvent: InputEvent
+
+            override fun onInputEvent(inputEvent: InputEvent) {
+                lastEvent = inputEvent
+            }
+        }
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(mock())
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.mainPanelEntity).thenReturn(mock())
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockRtEntity)
+        whenever(mockRtEntity.addComponent(any())).thenReturn(true)
+        whenever(mockRuntime.createPointerCaptureComponent(any(), any(), any()))
+            .thenReturn(mockRtComponent)
+
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun addComponent_addsRuntimeComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        val pointerCaptureComponent =
+            PointerCaptureComponent.create(session, directExecutor(), stateListener, inputListener)
+        assertThat(entity.addComponent(pointerCaptureComponent)).isTrue()
+
+        verify(mockRtEntity).addComponent(any())
+        verify(mockRuntime).createPointerCaptureComponent(any(), any(), any())
+    }
+
+    @Test
+    fun addComponent_failsIfAlreadyAttached() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        val pointerCaptureComponent =
+            PointerCaptureComponent.create(session, directExecutor(), stateListener, inputListener)
+        assertThat(entity.addComponent(pointerCaptureComponent)).isTrue()
+        assertThat(entity.addComponent(pointerCaptureComponent)).isFalse()
+    }
+
+    @Test
+    fun stateListener_propagatesCorrectlyFromRuntime() {
+        val entity = session.createEntity("test")
+        val pointerCaptureComponent =
+            PointerCaptureComponent.create(session, directExecutor(), stateListener, inputListener)
+        val stateListenerCaptor =
+            argumentCaptor<JxrPlatformAdapter.PointerCaptureComponent.StateListener>()
+
+        assertThat(entity.addComponent(pointerCaptureComponent)).isTrue()
+        verify(mockRuntime)
+            .createPointerCaptureComponent(any(), stateListenerCaptor.capture(), any())
+
+        // Verify all states are properly converted and propagated.
+        val stateListenerCaptured: JxrPlatformAdapter.PointerCaptureComponent.StateListener =
+            stateListenerCaptor.lastValue
+        stateListenerCaptured.onStateChanged(
+            JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_ACTIVE
+        )
+        assertThat(stateListener.lastState)
+            .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_ACTIVE)
+
+        stateListenerCaptured.onStateChanged(
+            JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_PAUSED
+        )
+        assertThat(stateListener.lastState)
+            .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_PAUSED)
+
+        stateListenerCaptured.onStateChanged(
+            JxrPlatformAdapter.PointerCaptureComponent.POINTER_CAPTURE_STATE_STOPPED
+        )
+        assertThat(stateListener.lastState)
+            .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_STOPPED)
+    }
+
+    @Test
+    fun inputEventListener_propagatesFromRuntime() {
+        val entity = session.createEntity("test")
+        val pointerCaptureComponent =
+            PointerCaptureComponent.create(session, directExecutor(), stateListener, inputListener)
+        val inputListenerCaptor = argumentCaptor<JxrPlatformAdapter.InputEventListener>()
+
+        assertThat(entity.addComponent(pointerCaptureComponent)).isTrue()
+        verify(mockRuntime)
+            .createPointerCaptureComponent(any(), any(), inputListenerCaptor.capture())
+
+        val inputEvent =
+            JxrPlatformAdapter.InputEvent(
+                JxrPlatformAdapter.InputEvent.SOURCE_HANDS,
+                JxrPlatformAdapter.InputEvent.POINTER_TYPE_LEFT,
+                100,
+                Vector3(),
+                Vector3(0f, 0f, 1f),
+                JxrPlatformAdapter.InputEvent.ACTION_DOWN,
+                JxrPlatformAdapter.InputEvent.HitInfo(mockRtEntity, Vector3.One, Matrix4.Identity),
+                null,
+            )
+
+        // Only compare non-floating point values for stability
+        inputListenerCaptor.lastValue.onInputEvent(inputEvent)
+        assertThat(inputListener.lastEvent.source).isEqualTo(InputEvent.SOURCE_HANDS)
+        assertThat(inputListener.lastEvent.pointerType).isEqualTo(InputEvent.POINTER_TYPE_LEFT)
+        assertThat(inputListener.lastEvent.timestamp).isEqualTo(inputEvent.timestamp)
+        assertThat(inputListener.lastEvent.action).isEqualTo(InputEvent.ACTION_DOWN)
+        assertThat(inputListener.lastEvent.hitInfo).isNotNull()
+        val hitInfo = inputListener.lastEvent.hitInfo!!
+        assertThat(hitInfo.inputEntity).isEqualTo(entity)
+        assertThat(hitInfo.hitPosition).isEqualTo(Vector3.One)
+        assertThat(hitInfo.transform).isEqualTo(Matrix4.Identity)
+        assertThat(inputListener.lastEvent.secondaryHitInfo).isNull()
+    }
+
+    @Test
+    fun removeComponent_removesRuntimeComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        val pointerCaptureComponent =
+            PointerCaptureComponent.create(session, directExecutor(), stateListener, inputListener)
+        assertThat(entity.addComponent(pointerCaptureComponent)).isTrue()
+
+        entity.removeComponent(pointerCaptureComponent)
+        verify(mockRtEntity).removeComponent(mockRtComponent)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ResizableComponentTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ResizableComponentTest.kt
new file mode 100644
index 0000000..4ce3987
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/ResizableComponentTest.kt
@@ -0,0 +1,357 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.kotlin.any
+import org.mockito.kotlin.firstValue
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.secondValue
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ResizableComponentTest {
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private lateinit var session: Session
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        whenever(mockRuntime.activitySpace).thenReturn(mock())
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mock())
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockRuntime.getMainPanelEntity()).thenReturn(mock())
+        whenever(mockRuntime.createEntity(any(), any(), any())).thenReturn(mockContentlessEntity)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun addResizableComponent_addsRuntimeResizableComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime, Dimensions(), Dimensions())
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        verify(mockRuntime).createResizableComponent(any(), any())
+        verify(mockContentlessEntity).addComponent(any())
+    }
+
+    @Test
+    fun addResizableComponentDefaultArguments_addsRuntimeResizableComponentWithDefaults() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.Dimensions::class.java)
+        verify(mockRuntime).createResizableComponent(captor.capture(), captor.capture())
+        val rtMinDimensions = captor.firstValue
+        val rtMaxDimensions = captor.secondValue
+        assertThat(rtMinDimensions.width).isEqualTo(0f)
+        assertThat(rtMinDimensions.height).isEqualTo(0f)
+        assertThat(rtMinDimensions.depth).isEqualTo(0f)
+        assertThat(rtMaxDimensions.width).isEqualTo(10f)
+        assertThat(rtMaxDimensions.height).isEqualTo(10f)
+        assertThat(rtMaxDimensions.depth).isEqualTo(10f)
+        verify(mockContentlessEntity).addComponent(any())
+    }
+
+    @Test
+    fun removeResizableComponent_removesRuntimeResizableComponent() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+
+        entity.removeComponent(resizableComponent)
+        verify(mockContentlessEntity).removeComponent(any())
+    }
+
+    @Test
+    fun resizableComponent_canAttachOnlyOnce() {
+        val entity = session.createEntity("test")
+        val entity2 = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        assertThat(entity2.addComponent(resizableComponent)).isFalse()
+    }
+
+    @Test
+    fun resizableComponent_setSizeInvokesRuntimeResizableComponentSetSize() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+
+        val testSize = Dimensions(2f, 2f, 0f)
+        resizableComponent.size = testSize
+
+        assertThat(resizableComponent.size).isEqualTo(testSize)
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.Dimensions::class.java)
+        verify(mockRtResizableComponent).setSize(captor.capture())
+        val rtDimensions = captor.value
+        assertThat(rtDimensions.width).isEqualTo(2f)
+        assertThat(rtDimensions.height).isEqualTo(2f)
+        assertThat(rtDimensions.depth).isEqualTo(0f)
+    }
+
+    @Test
+    fun resizableComponent_setMinimumSizeInvokesRuntimeResizableComponentSetMinimumSize() {
+        val entity = session.createEntity("test")
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.Dimensions::class.java)
+        assertThat(entity).isNotNull()
+
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+
+        val testSize = Dimensions(0.5f, 0.6f, 0.7f)
+        resizableComponent.minimumSize = testSize
+
+        assertThat(resizableComponent.minimumSize).isEqualTo(testSize)
+        verify(mockRtResizableComponent).setMinimumSize(captor.capture())
+        val rtDimensions = captor.value
+        assertThat(rtDimensions.width).isEqualTo(0.5f)
+        assertThat(rtDimensions.height).isEqualTo(0.6f)
+        assertThat(rtDimensions.depth).isEqualTo(0.7f)
+    }
+
+    @Test
+    fun resizableComponent_setMaximumSizeInvokesRuntimeResizableComponentSetMaximumSize() {
+        val entity = session.createEntity("test")
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.Dimensions::class.java)
+        assertThat(entity).isNotNull()
+
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+
+        val testSize = Dimensions(5f, 6f, 7f)
+        resizableComponent.maximumSize = testSize
+
+        assertThat(resizableComponent.maximumSize).isEqualTo(testSize)
+        verify(mockRtResizableComponent).setMaximumSize(captor.capture())
+        val rtDimensions = captor.value
+        assertThat(rtDimensions.width).isEqualTo(5f)
+        assertThat(rtDimensions.height).isEqualTo(6f)
+        assertThat(rtDimensions.depth).isEqualTo(7f)
+    }
+
+    @Test
+    fun addResizeListener_invokesRuntimeAddResizeEventListener() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        val mockResizeListener = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.ResizeEventListener::class.java)
+        // Capture the runtime resize event listener that is provided to the runtime resizable
+        // component.
+        verify(mockRtResizableComponent).addResizeEventListener(any(), captor.capture())
+        val rtResizeEventListener = captor.value
+        var rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START,
+                JxrPlatformAdapter.Dimensions(1f, 1f, 1f),
+            )
+        // Invoke the runtime resize event listener with a resize event.
+        rtResizeEventListener.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener).onResizeStart(any(), any())
+        rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_ONGOING,
+                JxrPlatformAdapter.Dimensions(2f, 2f, 2f),
+            )
+        rtResizeEventListener.onResizeEvent(rtResizeEvent)
+        rtResizeEventListener.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener, times(2)).onResizeUpdate(any(), any())
+        rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_END,
+                JxrPlatformAdapter.Dimensions(2f, 2f, 2f),
+            )
+        rtResizeEventListener.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener).onResizeEnd(any(), any())
+    }
+
+    @Test
+    fun addMultipleResizeEventListeners_invokesAllListeners() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        val mockResizeListener = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener)
+        val mockResizeListener2 = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener2)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.ResizeEventListener::class.java)
+        // Capture the runtime resize event listener that is provided to the runtime resizable
+        // component.
+        verify(mockRtResizableComponent, times(2)).addResizeEventListener(any(), captor.capture())
+        val rtResizeEventListener1 = captor.allValues[0]
+        val rtResizeEventListener2 = captor.allValues[1]
+        val rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START,
+                JxrPlatformAdapter.Dimensions(1f, 1f, 1f),
+            )
+        // Invoke the runtime resize event listener with a resize event.
+        rtResizeEventListener1.onResizeEvent(rtResizeEvent)
+        rtResizeEventListener2.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener).onResizeStart(any(), any())
+        verify(mockResizeListener2).onResizeStart(any(), any())
+    }
+
+    @Test
+    fun removeResizeEventListener_invokesRuntimeRemoveResizeEventListener() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        val mockResizeListener = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener)
+        val mockResizeListener2 = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener2)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.ResizeEventListener::class.java)
+        // Capture the runtime resize event listener that is provided to the runtime resizable
+        // component.
+        verify(mockRtResizableComponent, times(2)).addResizeEventListener(any(), captor.capture())
+        val rtResizeEventListener1 = captor.allValues[0]
+        val rtResizeEventListener2 = captor.allValues[1]
+        val rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START,
+                JxrPlatformAdapter.Dimensions(1f, 1f, 1f),
+            )
+        // Invoke the runtime resize event listener with a resize event.
+        rtResizeEventListener1.onResizeEvent(rtResizeEvent)
+        rtResizeEventListener2.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener).onResizeStart(any(), any())
+        verify(mockResizeListener2).onResizeStart(any(), any())
+
+        resizableComponent.removeResizeListener(mockResizeListener)
+        resizableComponent.removeResizeListener(mockResizeListener2)
+        verify(mockRtResizableComponent).removeResizeEventListener(rtResizeEventListener1)
+        verify(mockRtResizableComponent).removeResizeEventListener(rtResizeEventListener2)
+    }
+
+    @Test
+    fun resizableComponent_canAttachAgainAfterDetach() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        entity.removeComponent(resizableComponent)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+    }
+
+    @Test
+    fun resizableComponent_attachAfterDetachPreservesListeners() {
+        val entity = session.createEntity("test")
+        assertThat(entity).isNotNull()
+        val mockRtResizableComponent = mock<JxrPlatformAdapter.ResizableComponent>()
+        whenever(mockRuntime.createResizableComponent(any(), any()))
+            .thenReturn(mockRtResizableComponent)
+        whenever(mockContentlessEntity.addComponent(any())).thenReturn(true)
+        val resizableComponent = ResizableComponent.create(mockRuntime)
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        val mockResizeListener = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener)
+        val mockResizeListener2 = mock<ResizeListener>()
+        resizableComponent.addResizeListener(directExecutor(), mockResizeListener2)
+
+        val captor = ArgumentCaptor.forClass(JxrPlatformAdapter.ResizeEventListener::class.java)
+        // Capture the runtime resize event listener that is provided to the runtime resizable
+        // component.
+        verify(mockRtResizableComponent, times(2)).addResizeEventListener(any(), captor.capture())
+        val rtResizeEventListener1 = captor.allValues[0]
+        val rtResizeEventListener2 = captor.allValues[1]
+        val rtResizeEvent =
+            JxrPlatformAdapter.ResizeEvent(
+                JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START,
+                JxrPlatformAdapter.Dimensions(1f, 1f, 1f),
+            )
+        // Invoke the runtime resize event listener with a resize event.
+        rtResizeEventListener1.onResizeEvent(rtResizeEvent)
+        rtResizeEventListener2.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener).onResizeStart(any(), any())
+        verify(mockResizeListener2).onResizeStart(any(), any())
+
+        // Detach and reattach the resizable component.
+        entity.removeComponent(resizableComponent)
+        assertThat(entity.addComponent(resizableComponent)).isTrue()
+        // Invoke the runtime resize event listener with a resize event.
+        rtResizeEventListener1.onResizeEvent(rtResizeEvent)
+        rtResizeEventListener2.onResizeEvent(rtResizeEvent)
+        verify(mockResizeListener, times(2)).onResizeStart(any(), any())
+        verify(mockResizeListener2, times(2)).onResizeStart(any(), any())
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SessionTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SessionTest.kt
new file mode 100644
index 0000000..e46350b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SessionTest.kt
@@ -0,0 +1,393 @@
+/*
+ * Copyright 2024 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.
+ */
+
+@file:Suppress("UNUSED_VARIABLE")
+
+package androidx.xr.scenecore
+
+import android.app.Activity
+import android.graphics.Rect
+import android.os.Bundle
+import android.widget.TextView
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import java.util.UUID
+import java.util.function.Consumer
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.anyString
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Unit tests for the JXRCore SDK Session Interface.
+ *
+ * TODO(b/329902726): Add a TestRuntime and verify CPM Integration.
+ */
+@RunWith(RobolectricTestRunner::class)
+class SessionTest {
+    private val activityController = Robolectric.buildActivity(Activity::class.java)
+    private val activity = activityController.create().start().get()
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    private val mockAnchorEntity = mock<JxrPlatformAdapter.AnchorEntity>()
+    lateinit var session: Session
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mock())
+        val mockActivitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+        whenever(mockRuntime.activitySpace).thenReturn(mockActivitySpace)
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.activitySpaceRootImpl).thenReturn(mockActivitySpace)
+        whenever(mockRuntime.mainPanelEntity).thenReturn(mock())
+        whenever(mockRuntime.perceptionSpaceActivityPose).thenReturn(mock())
+        whenever(mockAnchorEntity.state)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.State.UNANCHORED)
+        whenever(mockAnchorEntity.persistState)
+            .thenReturn(JxrPlatformAdapter.AnchorEntity.PersistState.PERSIST_NOT_REQUESTED)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun requestFullSpaceMode_callsThrough() {
+        session.requestFullSpaceMode()
+        verify(mockRuntime).requestFullSpaceMode()
+    }
+
+    @Test
+    fun requestHomeSpaceMode_callsThrough() {
+        session.requestHomeSpaceMode()
+        verify(mockRuntime).requestHomeSpaceMode()
+    }
+
+    @Test
+    fun createSession_runtimeProvided_createsSessionWithProvidedRuntime() {
+        assertThat(session).isNotNull()
+        assertThat(session.runtime).isNotNull()
+        assertThat(session.runtime).isEqualTo(mockRuntime)
+    }
+
+    @Test
+    fun createSession_twiceFromSameActivity_returnsSameInstance() {
+        val newSession = Session.create(activity, mockRuntime)
+        assertThat(session).isEqualTo(newSession)
+    }
+
+    @Test
+    fun createSession_differentActivities_returnsUniqueInstances() {
+        val newActivity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+        val newSession = Session.create(newActivity, mockRuntime)
+        assertThat(session).isNotEqualTo(newSession)
+    }
+
+    @Test
+    fun createGltfResourceAsync_callsRuntimeLoadGltf() {
+        val mockGltfModelResource = mock<GltfModelResource>()
+        whenever(mockRuntime.loadGltfByAssetNameSplitEngine(anyString()))
+            .thenReturn(Futures.immediateFuture(mockGltfModelResource))
+        val unused = session.createGltfResourceAsync("test.glb")
+
+        verify(mockRuntime).loadGltfByAssetNameSplitEngine("test.glb")
+    }
+
+    @Test
+    fun createGltfEntity_callsRuntimeCreateGltfEntity() {
+        whenever(mockRuntime.loadGltfByAssetNameSplitEngine(anyString()))
+            .thenReturn(Futures.immediateFuture(mock()))
+        whenever(mockRuntime.createGltfEntity(any(), any(), any())).thenReturn(mock())
+        val gltfModelFuture = session.createGltfResourceAsync("test.glb")
+        val unused = session.createGltfEntity(gltfModelFuture.get())
+
+        verify(mockRuntime).loadGltfByAssetNameSplitEngine(eq("test.glb"))
+        verify(mockRuntime).createGltfEntity(any(), any(), any())
+    }
+
+    @Test
+    fun createPanelEntity_callsRuntimeCreatePanelEntity() {
+        val view = TextView(activity)
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mock())
+        val unused =
+            session.createPanelEntity(
+                view,
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test"
+            )
+
+        verify(mockRuntime).createPanelEntity(any(), any(), any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun createAnchorEntity_callsRuntimeCreateAnchorEntity() {
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), anyOrNull()))
+            .thenReturn(mockAnchorEntity)
+        val unused = session.createAnchorEntity(Dimensions(), PlaneType.ANY, PlaneSemantic.ANY)
+
+        verify(mockRuntime).createAnchorEntity(any(), any(), any(), anyOrNull())
+    }
+
+    @Test
+    fun getActivitySpace_returnsActivitySpace() {
+        val activitySpace = session.activitySpace
+
+        assertThat(activitySpace).isNotNull()
+    }
+
+    @Test
+    fun getActivitySpaceTwice_returnsSameSpace() {
+        val activitySpace1 = session.activitySpace
+        val activitySpace2 = session.activitySpace
+
+        assertThat(activitySpace1).isEqualTo(activitySpace2)
+    }
+
+    @Test
+    fun getActivitySpaceRoot_returnsActivitySpaceRoot() {
+        val activitySpaceRoot = session.activitySpaceRoot
+
+        assertThat(activitySpaceRoot).isNotNull()
+    }
+
+    @Test
+    fun getActivitySpaceRootTwice_returnsSameSpace() {
+        val activitySpaceRoot1 = session.activitySpaceRoot
+        val activitySpaceRoot2 = session.activitySpaceRoot
+
+        assertThat(activitySpaceRoot1).isEqualTo(activitySpaceRoot2)
+    }
+
+    @Test
+    fun getSpatialUser_returnsSpatialUser() {
+        val spatialUser = session.spatialUser
+
+        assertThat(spatialUser).isNotNull()
+    }
+
+    @Test
+    fun getSpatialUserTwice_returnsSameUser() {
+        val spatialUser1 = session.spatialUser
+        val spatialUser2 = session.spatialUser
+
+        assertThat(spatialUser1).isEqualTo(spatialUser2)
+    }
+
+    @Test
+    fun getPerceptionSpace_returnPerceptionSpace() {
+        val perceptionSpace = session.perceptionSpace
+
+        assertThat(perceptionSpace).isNotNull()
+    }
+
+    @Test
+    fun createActivityPanelEntity_callsRuntimeCreateActivityPanelEntity() {
+        whenever(mockRuntime.createActivityPanelEntity(any(), any(), any(), any(), any()))
+            .thenReturn(mock())
+        val unused = session.createActivityPanelEntity(Rect(0, 0, 640, 480), "test")
+
+        verify(mockRuntime).createActivityPanelEntity(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun getMainPanelEntity_returnsPanelEntity() {
+        val unused = session.mainPanelEntity
+        val unusedAgain = session.mainPanelEntity
+
+        verify(mockRuntime, times(1)).mainPanelEntity
+    }
+
+    @Test
+    fun createPersistedAnchorEntity_callsRuntimecreatePersistedAnchorEntity() {
+        whenever(mockRuntime.createPersistedAnchorEntity(any(), any())).thenReturn(mockAnchorEntity)
+        val unused = session.createPersistedAnchorEntity(UUID.randomUUID())
+
+        verify(mockRuntime).createPersistedAnchorEntity(any(), any())
+    }
+
+    @Test
+    fun unpersistAnchor_callsRuntimeunpersistAnchor_returnsTrue() {
+        val uuid = UUID.randomUUID()
+        whenever(mockRuntime.unpersistAnchor(uuid)).thenReturn(true)
+        assertThat(session.unpersistAnchor(uuid)).isTrue()
+        verify(mockRuntime).unpersistAnchor(uuid)
+    }
+
+    fun unpersistAnchor_callsRuntimeunpersistAnchor_returnsFalse() {
+        val uuid = UUID.randomUUID()
+        whenever(mockRuntime.unpersistAnchor(uuid)).thenReturn(false)
+        assertThat(session.unpersistAnchor(uuid)).isFalse()
+        verify(mockRuntime).unpersistAnchor(uuid)
+    }
+
+    @Test
+    fun createInteractableComponent_callsRuntimeCreateInteractableComponent() {
+        whenever(mockRuntime.createInteractableComponent(any(), any())).thenReturn(mock())
+
+        val interactableComponent = session.createInteractableComponent(directExecutor(), mock())
+        val view = TextView(activity)
+        val mockPanelEntity = mock<JxrPlatformAdapter.PanelEntity>()
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockPanelEntity)
+        whenever(mockPanelEntity.addComponent(any())).thenReturn(true)
+        val panelEntity =
+            session.createPanelEntity(
+                view,
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test"
+            )
+        assertThat(panelEntity.addComponent(interactableComponent)).isTrue()
+
+        verify(mockRuntime).createInteractableComponent(any(), anyOrNull())
+    }
+
+    @Test
+    fun createMovableComponent_callsRuntimeCreateMovableComponent() {
+        whenever(mockRuntime.createMovableComponent(any(), any(), any(), any())).thenReturn(mock())
+
+        val movableComponent = session.createMovableComponent()
+        val view = TextView(activity)
+        val mockRtPanelEntity = mock<JxrPlatformAdapter.PanelEntity>()
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockRtPanelEntity)
+        whenever(mockRtPanelEntity.addComponent(any())).thenReturn(true)
+        val panelEntity =
+            session.createPanelEntity(
+                view,
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test"
+            )
+        assertThat(panelEntity.addComponent(movableComponent)).isTrue()
+
+        verify(mockRuntime).createMovableComponent(any(), any(), any(), any())
+    }
+
+    @Test
+    fun createResizableComponent_callsRuntimeCreateResizableComponent() {
+        whenever(mockRuntime.createResizableComponent(any(), any())).thenReturn(mock())
+
+        val resizableComponent = session.createResizableComponent()
+        val view = TextView(activity)
+        val mockRtPanelEntity = mock<JxrPlatformAdapter.PanelEntity>()
+        whenever(mockRtPanelEntity.getSize()).thenReturn(JxrPlatformAdapter.Dimensions(1f, 1f, 1f))
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockRtPanelEntity)
+        whenever(mockRtPanelEntity.addComponent(any())).thenReturn(true)
+        val panelEntity =
+            session.createPanelEntity(
+                view,
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test"
+            )
+        assertThat(panelEntity.addComponent(resizableComponent)).isTrue()
+
+        verify(mockRuntime).createResizableComponent(any(), any())
+    }
+
+    @Test
+    fun setFullSpaceMode_callsThrough() {
+        // Test that Session calls into the runtime.
+        val bundle = Bundle().apply { putString("testkey", "testval") }
+        whenever(mockRuntime.setFullSpaceMode(any())).thenReturn(bundle)
+        val unused = session.setFullSpaceMode(bundle)
+        verify(mockRuntime).setFullSpaceMode(bundle)
+    }
+
+    @Test
+    fun setFullSpaceModeWithEnvironmentInherited_callsThrough() {
+        // Test that Session calls into the runtime.
+        val bundle = Bundle().apply { putString("testkey", "testval") }
+        whenever(mockRuntime.setFullSpaceModeWithEnvironmentInherited(any())).thenReturn(bundle)
+        val unused = session.setFullSpaceModeWithEnvironmentInherited(bundle)
+        verify(mockRuntime).setFullSpaceModeWithEnvironmentInherited(bundle)
+    }
+
+    @Test
+    fun setPreferredAspectRatio_callsThrough() {
+        // Test that Session calls into the runtime.
+        session.setPreferredAspectRatio(activity, 1.23f)
+        verify(mockRuntime).setPreferredAspectRatio(activity, 1.23f)
+    }
+
+    @Test
+    fun getPanelEntityType_returnsAllPanelEntities() {
+        val mockPanelEntity1 = mock<JxrPlatformAdapter.PanelEntity>()
+        val mockActivityPanelEntity = mock<JxrPlatformAdapter.ActivityPanelEntity>()
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockPanelEntity1)
+        whenever(mockRuntime.createActivityPanelEntity(any(), any(), any(), any(), any()))
+            .thenReturn(mockActivityPanelEntity)
+        val panelEntity =
+            session.createPanelEntity(
+                TextView(activity),
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test1",
+            )
+        val activityPanelEntity = session.createActivityPanelEntity(Rect(0, 0, 640, 480), "test2")
+
+        assertThat(session.getEntitiesOfType(PanelEntity::class.java))
+            .containsAtLeast(panelEntity, activityPanelEntity)
+    }
+
+    @Test
+    fun getEntitiesBaseType_returnsAllEntities() {
+        val mockPanelEntity = mock<JxrPlatformAdapter.PanelEntity>()
+        whenever(mockRuntime.createPanelEntity(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(mockPanelEntity)
+        whenever(mockRuntime.createAnchorEntity(any(), any(), any(), any()))
+            .thenReturn(mockAnchorEntity)
+        val panelEntity =
+            session.createPanelEntity(
+                TextView(activity),
+                Dimensions(720f, 480f),
+                Dimensions(0.1f, 0.1f, 0.1f),
+                "test1",
+            )
+        val anchorEntity =
+            session.createAnchorEntity(Dimensions(), PlaneType.ANY, PlaneSemantic.ANY)
+
+        assertThat(session.getEntitiesOfType(Entity::class.java))
+            .containsAtLeast(panelEntity, anchorEntity)
+    }
+
+    @Test
+    fun addAndRemoveSpatialCapabilitiesChangedListener_callsRuntimeAddAndRemove() {
+        val listener = Consumer<SpatialCapabilities> { _ -> }
+        session.addSpatialCapabilitiesChangedListener(listener = listener)
+        verify(mockRuntime).addSpatialCapabilitiesChangedListener(any(), any())
+        session.removeSpatialCapabilitiesChangedListener(listener)
+        verify(mockRuntime).removeSpatialCapabilitiesChangedListener(any())
+    }
+
+    @Test
+    fun onDestroy_callsRuntimeDispose() {
+        activityController.destroy()
+        verify(mockRuntime).dispose()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SoundFieldAttributesTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SoundFieldAttributesTest.kt
new file mode 100644
index 0000000..e2642e0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SoundFieldAttributesTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SoundFieldAttributesTest {
+
+    @Test
+    fun init_createsCorrectRuntimeAmbisonicsIntDef() {
+        val firstOrderAttributes =
+            SoundFieldAttributes(SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER)
+        val firstOrderRtAttributes = firstOrderAttributes.rtSoundFieldAttributes
+        assertThat(firstOrderRtAttributes.ambisonicsOrder)
+            .isEqualTo(JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER)
+
+        val secondOrderAttributes =
+            SoundFieldAttributes(SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER)
+        val secondOrderRtAttributes = secondOrderAttributes.rtSoundFieldAttributes
+        assertThat(secondOrderRtAttributes.ambisonicsOrder)
+            .isEqualTo(JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER)
+
+        val thirdOrderAttributes =
+            SoundFieldAttributes(SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER)
+        val thirdOrderRtAttributes = thirdOrderAttributes.rtSoundFieldAttributes
+        assertThat(thirdOrderRtAttributes.ambisonicsOrder)
+            .isEqualTo(JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialAudioTrackTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialAudioTrackTest.kt
new file mode 100644
index 0000000..35d99c0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialAudioTrackTest.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.media.AudioTrack
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argWhere
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SpatialAudioTrackTest {
+
+    private var mockRuntime: JxrPlatformAdapter = mock()
+    private var mockRtAudioTrackExtensions: JxrPlatformAdapter.AudioTrackExtensionsWrapper = mock()
+
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+
+    private lateinit var session: Session
+
+    @Before
+    fun setUp() {
+        mockRuntime.stub {
+            on { spatialEnvironment } doReturn mock()
+            on { activitySpace } doReturn mock()
+            on { activitySpaceRootImpl } doReturn mock()
+            on { headActivityPose } doReturn mock()
+            on { perceptionSpaceActivityPose } doReturn mock()
+            on { mainPanelEntity } doReturn mock()
+            on { createEntity(any(), any(), any()) } doReturn mockContentlessEntity
+        }
+
+        mockRtAudioTrackExtensions = mock()
+        whenever(mockRuntime.audioTrackExtensionsWrapper).thenReturn(mockRtAudioTrackExtensions)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun setWithPointSource_callsRuntimeAudioTrackBuilderSetPointSource() {
+        val builder = AudioTrack.Builder()
+
+        val entity = session.createEntity("test")
+        val pointSourceAttributes = PointSourceAttributes(entity)
+
+        whenever(
+                mockRtAudioTrackExtensions.setPointSourceAttributes(
+                    eq(builder),
+                    any<JxrPlatformAdapter.PointSourceAttributes>(),
+                )
+            )
+            .thenReturn(builder)
+
+        val actualBuilder =
+            SpatialAudioTrackBuilder.setPointSourceAttributes(
+                session,
+                builder,
+                pointSourceAttributes
+            )
+
+        verify(mockRtAudioTrackExtensions)
+            .setPointSourceAttributes(
+                eq(builder),
+                argWhere<JxrPlatformAdapter.PointSourceAttributes> {
+                    it.entity == mockContentlessEntity
+                },
+            )
+        assertThat(actualBuilder).isEqualTo(builder)
+    }
+
+    @Test
+    fun setWithSoundField_callsRuntimeAudioTrackBuilderSetSoundField() {
+        val builder = AudioTrack.Builder()
+        val soundFieldAttributes =
+            SoundFieldAttributes(SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER)
+
+        whenever(
+                mockRtAudioTrackExtensions.setSoundFieldAttributes(
+                    eq(builder),
+                    any<JxrPlatformAdapter.SoundFieldAttributes>(),
+                )
+            )
+            .thenReturn(builder)
+
+        val actualBuilder =
+            SpatialAudioTrackBuilder.setSoundFieldAttributes(session, builder, soundFieldAttributes)
+
+        verify(mockRtAudioTrackExtensions)
+            .setSoundFieldAttributes(
+                eq(builder),
+                argWhere<JxrPlatformAdapter.SoundFieldAttributes> {
+                    it.ambisonicsOrder == SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER
+                },
+            )
+        assertThat(actualBuilder).isEqualTo(builder)
+    }
+
+    @Test
+    fun getSourceType_callsRuntimeAudioTrackGetSourceType() {
+        val audioTrack = AudioTrack.Builder().build()
+        val expectedSourceType = JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_POINT_SOURCE
+
+        whenever(mockRtAudioTrackExtensions.getSpatialSourceType(eq(audioTrack)))
+            .thenReturn(expectedSourceType)
+
+        val sourceType = SpatialAudioTrack.getSpatialSourceType(session, audioTrack)
+
+        verify(mockRtAudioTrackExtensions).getSpatialSourceType(eq(audioTrack))
+        assertThat(sourceType).isEqualTo(expectedSourceType)
+    }
+
+    @Test
+    fun getPointSourceAttributes_callsRuntimeAudioTrackGetPointSourceAttributes() {
+        val audioTrack = AudioTrack.Builder().build()
+        val entity = session.createEntity("test")
+
+        val temp: BaseEntity<*> = entity as BaseEntity<*>
+        val rtEntity = temp.rtEntity
+        val rtPointSourceAttributes = JxrPlatformAdapter.PointSourceAttributes(rtEntity)
+
+        whenever(mockRtAudioTrackExtensions.getPointSourceAttributes(eq(audioTrack)))
+            .thenReturn(rtPointSourceAttributes)
+
+        val pointSourceAttributes = SpatialAudioTrack.getPointSourceAttributes(session, audioTrack)
+
+        verify(mockRtAudioTrackExtensions).getPointSourceAttributes(eq(audioTrack))
+        assertThat((pointSourceAttributes!!.entity as BaseEntity<*>).rtEntity).isEqualTo(rtEntity)
+    }
+
+    @Test
+    fun getPointSourceAttributes_returnsNullIfNotInRuntime() {
+        val audioTrack = AudioTrack.Builder().build()
+
+        whenever(mockRtAudioTrackExtensions.getPointSourceAttributes(eq(audioTrack)))
+            .thenReturn(null)
+
+        val pointSourceAttributes = SpatialAudioTrack.getPointSourceAttributes(session, audioTrack)
+
+        assertThat(pointSourceAttributes).isNull()
+    }
+
+    @Test
+    fun getSoundFieldAttributes_callsRuntimeAudioTrackGetPointSourceAttributes() {
+        val audioTrack = AudioTrack.Builder().build()
+        val expectedAmbisonicsOrder = SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER
+        val rtSoundFieldAttributes =
+            JxrPlatformAdapter.SoundFieldAttributes(expectedAmbisonicsOrder)
+
+        whenever(mockRtAudioTrackExtensions.getSoundFieldAttributes(eq(audioTrack)))
+            .thenReturn(rtSoundFieldAttributes)
+
+        val soundFieldAttributes = SpatialAudioTrack.getSoundFieldAttributes(session, audioTrack)
+
+        verify(mockRtAudioTrackExtensions).getSoundFieldAttributes(eq(audioTrack))
+        assertThat(soundFieldAttributes?.order).isEqualTo(expectedAmbisonicsOrder)
+    }
+
+    @Test
+    fun getSoundFieldAttributes_returnsNullIfNotInRuntime() {
+        val audioTrack = AudioTrack.Builder().build()
+
+        whenever(mockRtAudioTrackExtensions.getSoundFieldAttributes(eq(audioTrack)))
+            .thenReturn(null)
+
+        val soundFieldAttributes = SpatialAudioTrack.getSoundFieldAttributes(session, audioTrack)
+
+        assertThat(soundFieldAttributes?.order).isNull()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialEnvironmentTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialEnvironmentTest.kt
new file mode 100644
index 0000000..1297b37
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialEnvironmentTest.kt
@@ -0,0 +1,318 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import com.google.common.truth.Truth.assertThat
+import java.util.function.Consumer
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+/**
+ * Unit tests for the JXRCore SDK SpatialEnvironment Interface.
+ *
+ * TODO(b/329902726): Add a TestRuntime and verify CPM Integration.
+ */
+@RunWith(JUnit4::class)
+class SpatialEnvironmentTest {
+
+    private var mockRuntime: JxrPlatformAdapter = mock<JxrPlatformAdapter>()
+    private var mockRtEnvironment: JxrPlatformAdapter.SpatialEnvironment? = null
+    private var environment: SpatialEnvironment? = null
+
+    @Before
+    fun setUp() {
+        mockRtEnvironment = mock<JxrPlatformAdapter.SpatialEnvironment>()
+        whenever(mockRuntime.spatialEnvironment).thenReturn(mockRtEnvironment)
+
+        environment = SpatialEnvironment(mockRuntime)
+    }
+
+    @Test
+    fun getCurrentPassthroughOpacity_getsRuntimePassthroughOpacity() {
+        val rtOpacity = 0.3f
+        whenever(mockRtEnvironment!!.currentPassthroughOpacity).thenReturn(rtOpacity)
+        assertThat(environment!!.getCurrentPassthroughOpacity()).isEqualTo(rtOpacity)
+        verify(mockRtEnvironment!!).currentPassthroughOpacity
+    }
+
+    @Test
+    fun getPassthroughOpacityPreference_getsRuntimePassthroughOpacityPreference() {
+        val rtPreference = 0.3f
+        whenever(mockRtEnvironment!!.passthroughOpacityPreference).thenReturn(rtPreference)
+
+        assertThat(environment!!.getPassthroughOpacityPreference()).isEqualTo(rtPreference)
+        verify(mockRtEnvironment!!).passthroughOpacityPreference
+    }
+
+    @Test
+    fun getPassthroughOpacityPreferenceNull_getsRuntimePassthroughOpacityPreference() {
+        val rtPreference = null as Float?
+        whenever(mockRtEnvironment!!.passthroughOpacityPreference).thenReturn(rtPreference)
+
+        assertThat(environment!!.getPassthroughOpacityPreference()).isEqualTo(rtPreference)
+        verify(mockRtEnvironment!!).passthroughOpacityPreference
+    }
+
+    @Test
+    fun setPassthroughOpacityPreference_callsRuntimeSetPassthroughOpacityPreference() {
+        val preference = 0.3f
+
+        whenever(mockRtEnvironment!!.setPassthroughOpacityPreference(any()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult
+                    .CHANGE_APPLIED
+            )
+        assertThat(environment!!.setPassthroughOpacityPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetPassthroughOpacityPreferenceChangeApplied::class.java
+            )
+
+        whenever(mockRtEnvironment!!.setPassthroughOpacityPreference(any()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult
+                    .CHANGE_PENDING
+            )
+        assertThat(environment!!.setPassthroughOpacityPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetPassthroughOpacityPreferenceChangePending::class.java
+            )
+
+        verify(mockRtEnvironment!!, times(2)).setPassthroughOpacityPreference(preference)
+    }
+
+    @Test
+    fun setPassthroughOpacityPreferenceNull_callsRuntimeSetPassthroughOpacityPreference() {
+        val preference = null as Float?
+
+        whenever(mockRtEnvironment!!.setPassthroughOpacityPreference(anyOrNull()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult
+                    .CHANGE_APPLIED
+            )
+        assertThat(environment!!.setPassthroughOpacityPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetPassthroughOpacityPreferenceChangeApplied::class.java
+            )
+
+        whenever(mockRtEnvironment!!.setPassthroughOpacityPreference(anyOrNull()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult
+                    .CHANGE_PENDING
+            )
+        assertThat(environment!!.setPassthroughOpacityPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetPassthroughOpacityPreferenceChangePending::class.java
+            )
+
+        verify(mockRtEnvironment!!, times(2)).setPassthroughOpacityPreference(preference)
+    }
+
+    @Test
+    fun addOnPassthroughOpacityChangedListener_ReceivesRuntimeOnPassthroughOpacityChangedEvents() {
+        var listenerCalledWithValue = 0.0f
+        val captor = argumentCaptor<Consumer<Float>>()
+        val listener = Consumer<Float> { floatValue: Float -> listenerCalledWithValue = floatValue }
+        environment!!.addOnPassthroughOpacityChangedListener(listener)
+        verify(mockRtEnvironment!!).addOnPassthroughOpacityChangedListener(captor.capture())
+        captor.firstValue.accept(0.3f)
+        assertThat(listenerCalledWithValue).isEqualTo(0.3f)
+    }
+
+    @Test
+    fun removeOnPassthroughOpacityChangedListener_callsRuntimeRemoveOnPassthroughOpacityChangedListener() {
+        val captor = argumentCaptor<Consumer<Float>>()
+        val listener = Consumer<Float> {}
+        environment!!.removeOnPassthroughOpacityChangedListener(listener)
+        verify(mockRtEnvironment!!).removeOnPassthroughOpacityChangedListener(captor.capture())
+        assertThat(captor.firstValue).isEqualTo(listener)
+    }
+
+    @Test
+    fun spatialEnvironmentPreferenceEqualsHashcode_returnsTrueIfAllPropertiesAreEqual() {
+        val rtImageMock = mock<JxrPlatformAdapter.ExrImageResource>()
+        val rtModelMock = mock<JxrPlatformAdapter.GltfModelResource>()
+        val rtPreference =
+            JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference(
+                rtImageMock,
+                rtModelMock
+            )
+        val preference =
+            SpatialEnvironment.SpatialEnvironmentPreference(
+                ExrImage(rtImageMock),
+                GltfModel(rtModelMock)
+            )
+
+        assertThat(preference).isEqualTo(rtPreference.toSpatialEnvironmentPreference())
+        assertThat(preference.hashCode())
+            .isEqualTo(rtPreference.toSpatialEnvironmentPreference().hashCode())
+    }
+
+    @Test
+    fun spatialEnvironmentPreferenceEqualsHashcode_returnsFalseIfAnyPropertiesAreNotEqual() {
+        val rtImageMock = mock<JxrPlatformAdapter.ExrImageResource>()
+        val rtModelMock = mock<JxrPlatformAdapter.GltfModelResource>()
+        val rtImageMock2 = mock<JxrPlatformAdapter.ExrImageResource>()
+        val rtModelMock2 = mock<JxrPlatformAdapter.GltfModelResource>()
+        val rtPreference =
+            JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference(
+                rtImageMock,
+                rtModelMock
+            )
+
+        val preferenceDiffGeometry =
+            SpatialEnvironment.SpatialEnvironmentPreference(
+                ExrImage(rtImageMock),
+                GltfModel(rtModelMock2),
+            )
+        assertThat(preferenceDiffGeometry)
+            .isNotEqualTo(rtPreference.toSpatialEnvironmentPreference())
+        assertThat(preferenceDiffGeometry.hashCode())
+            .isNotEqualTo(rtPreference.toSpatialEnvironmentPreference().hashCode())
+
+        val preferenceDiffSkybox =
+            SpatialEnvironment.SpatialEnvironmentPreference(
+                ExrImage(rtImageMock2),
+                GltfModel(rtModelMock),
+            )
+        assertThat(preferenceDiffSkybox).isNotEqualTo(rtPreference.toSpatialEnvironmentPreference())
+        assertThat(preferenceDiffSkybox.hashCode())
+            .isNotEqualTo(rtPreference.toSpatialEnvironmentPreference().hashCode())
+    }
+
+    @Test
+    fun setSpatialEnvironmentPreference_returnsRuntimeEnvironmentResultObject() {
+        val rtImageMock = mock<JxrPlatformAdapter.ExrImageResource>()
+        val rtModelMock = mock<JxrPlatformAdapter.GltfModelResource>()
+
+        val preference =
+            SpatialEnvironment.SpatialEnvironmentPreference(
+                ExrImage(rtImageMock),
+                GltfModel(rtModelMock)
+            )
+
+        whenever(mockRtEnvironment!!.setSpatialEnvironmentPreference(any()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult
+                    .CHANGE_APPLIED
+            )
+        assertThat(environment!!.setSpatialEnvironmentPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetSpatialEnvironmentPreferenceChangeApplied::class.java
+            )
+
+        whenever(mockRtEnvironment!!.setSpatialEnvironmentPreference(any()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult
+                    .CHANGE_PENDING
+            )
+        assertThat(environment!!.setSpatialEnvironmentPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetSpatialEnvironmentPreferenceChangePending::class.java
+            )
+
+        verify(mockRtEnvironment!!, times(2)).setSpatialEnvironmentPreference(any())
+    }
+
+    @Test
+    fun setSpatialEnvironmentPreferenceNull_returnsRuntimeEnvironmentResultObject() {
+        val preference = null as SpatialEnvironment.SpatialEnvironmentPreference?
+
+        whenever(mockRtEnvironment!!.setSpatialEnvironmentPreference(anyOrNull()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult
+                    .CHANGE_APPLIED
+            )
+        assertThat(environment!!.setSpatialEnvironmentPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetSpatialEnvironmentPreferenceChangeApplied::class.java
+            )
+
+        whenever(mockRtEnvironment!!.setSpatialEnvironmentPreference(anyOrNull()))
+            .thenReturn(
+                JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult
+                    .CHANGE_PENDING
+            )
+        assertThat(environment!!.setSpatialEnvironmentPreference(preference))
+            .isInstanceOf(
+                SpatialEnvironment.SetSpatialEnvironmentPreferenceChangePending::class.java
+            )
+
+        verify(mockRtEnvironment!!, times(2)).setSpatialEnvironmentPreference(anyOrNull())
+    }
+
+    @Test
+    fun getSpatialEnvironmentPreference_getsRuntimeEnvironmentSpatialEnvironmentPreference() {
+        val rtImageMock = mock<JxrPlatformAdapter.ExrImageResource>()
+        val rtModelMock = mock<JxrPlatformAdapter.GltfModelResource>()
+        val rtPreference =
+            JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference(
+                rtImageMock,
+                rtModelMock
+            )
+        whenever(mockRtEnvironment!!.spatialEnvironmentPreference).thenReturn(rtPreference)
+
+        assertThat(environment!!.getSpatialEnvironmentPreference())
+            .isEqualTo(rtPreference.toSpatialEnvironmentPreference())
+        verify(mockRtEnvironment!!).spatialEnvironmentPreference
+    }
+
+    @Test
+    fun getSpatialEnvironmentPreferenceNull_getsRuntimeEnvironmentSpatialEnvironmentPreference() {
+        val rtPreference =
+            null as JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference?
+        whenever(mockRtEnvironment!!.spatialEnvironmentPreference).thenReturn(rtPreference)
+
+        assertThat(environment!!.getSpatialEnvironmentPreference()).isEqualTo(null)
+        verify(mockRtEnvironment!!).spatialEnvironmentPreference
+    }
+
+    @Test
+    fun isSpatialEnvironmentPreferenceActive_callsRuntimeEnvironmentisSpatialEnvironmentPreferenceActive() {
+        whenever(mockRtEnvironment!!.isSpatialEnvironmentPreferenceActive).thenReturn(true)
+        assertThat(environment!!.isSpatialEnvironmentPreferenceActive()).isTrue()
+        verify(mockRtEnvironment!!).isSpatialEnvironmentPreferenceActive
+    }
+
+    @Test
+    fun addOnSpatialEnvironmentChangedListener_ReceivesRuntimeEnvironmentOnEnvironmentChangedEvents() {
+        var listenerCalled = false
+        val captor = argumentCaptor<Consumer<Boolean>>()
+        val listener = Consumer<Boolean> { called: Boolean -> listenerCalled = called }
+        environment!!.addOnSpatialEnvironmentChangedListener(listener)
+        verify(mockRtEnvironment!!).addOnSpatialEnvironmentChangedListener(captor.capture())
+        captor.firstValue.accept(true)
+        assertThat(listenerCalled).isTrue()
+    }
+
+    @Test
+    fun removeOnSpatialEnvironmentChangedListener_callsRuntimeRemoveOnSpatialEnvironmentChangedListener() {
+        val captor = argumentCaptor<Consumer<Boolean>>()
+        val listener = Consumer<Boolean> {}
+        environment!!.removeOnSpatialEnvironmentChangedListener(listener)
+        verify(mockRtEnvironment!!).removeOnSpatialEnvironmentChangedListener(captor.capture())
+        assertThat(listener).isEqualTo(captor.firstValue)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialMediaPlayerTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialMediaPlayerTest.kt
new file mode 100644
index 0000000..405c604
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialMediaPlayerTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.media.MediaPlayer
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argWhere
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SpatialMediaPlayerTest {
+
+    private var mockRuntime: JxrPlatformAdapter = mock()
+    private var mockRtMediaPlayerExtensions: JxrPlatformAdapter.MediaPlayerExtensionsWrapper =
+        mock()
+
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+
+    private lateinit var session: Session
+
+    @Before
+    fun setUp() {
+        mockRuntime.stub {
+            on { spatialEnvironment } doReturn mock()
+            on { activitySpace } doReturn mock()
+            on { activitySpaceRootImpl } doReturn mock()
+            on { headActivityPose } doReturn mock()
+            on { perceptionSpaceActivityPose } doReturn mock()
+            on { mainPanelEntity } doReturn mock()
+            on { createEntity(any(), any(), any()) } doReturn mockContentlessEntity
+        }
+
+        mockRtMediaPlayerExtensions = mock()
+        whenever(mockRuntime.mediaPlayerExtensionsWrapper).thenReturn(mockRtMediaPlayerExtensions)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun setWithPointSource_callsRuntimeMediaPlayerSetPointSource() {
+        val mediaPlayer = MediaPlayer()
+
+        val entity = session.createEntity("test")
+        val pointSourceAttributes = PointSourceAttributes(entity)
+
+        SpatialMediaPlayer.setPointSourceAttributes(session, mediaPlayer, pointSourceAttributes)
+
+        verify(mockRtMediaPlayerExtensions)
+            .setPointSourceAttributes(
+                eq(mediaPlayer),
+                argWhere<JxrPlatformAdapter.PointSourceAttributes> {
+                    it.entity == mockContentlessEntity
+                },
+            )
+    }
+
+    @Test
+    fun setWithSoundField_callsRuntimeMediaPlayerSetSoundField() {
+        val mediaPlayer = MediaPlayer()
+
+        val soundFieldAttributes =
+            SoundFieldAttributes(SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER)
+
+        SpatialMediaPlayer.setSoundFieldAttributes(session, mediaPlayer, soundFieldAttributes)
+
+        verify(mockRtMediaPlayerExtensions)
+            .setSoundFieldAttributes(
+                eq(mediaPlayer),
+                argWhere<JxrPlatformAdapter.SoundFieldAttributes> {
+                    it.ambisonicsOrder == SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER
+                },
+            )
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialSoundPoolTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialSoundPoolTest.kt
new file mode 100644
index 0000000..991db52
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialSoundPoolTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import android.app.Activity
+import android.media.SoundPool
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundPoolExtensionsWrapper
+import androidx.xr.scenecore.SpatializerConstants.Companion.AMBISONICS_ORDER_FIRST_ORDER
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argWhere
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+/** Unit tests for the JXRCore SDK SpatialSoundPool Interface. */
+@RunWith(RobolectricTestRunner::class)
+class SpatialSoundPoolTest {
+
+    private var mockRuntime: JxrPlatformAdapter = mock()
+    private var mockRtSoundPoolExtensions: SoundPoolExtensionsWrapper = mock()
+
+    private val mockContentlessEntity = mock<JxrPlatformAdapter.Entity>()
+    private val activity = Robolectric.buildActivity(Activity::class.java).create().start().get()
+
+    private lateinit var session: Session
+
+    @Before
+    fun setUp() {
+        mockRuntime.stub {
+            on { spatialEnvironment } doReturn mock()
+            on { activitySpace } doReturn mock()
+            on { activitySpaceRootImpl } doReturn mock()
+            on { headActivityPose } doReturn mock()
+            on { perceptionSpaceActivityPose } doReturn mock()
+            on { mainPanelEntity } doReturn mock()
+            on { createEntity(any(), any(), any()) } doReturn mockContentlessEntity
+        }
+
+        mockRtSoundPoolExtensions = mock()
+        whenever(mockRuntime.soundPoolExtensionsWrapper).thenReturn(mockRtSoundPoolExtensions)
+        session = Session.create(activity, mockRuntime)
+    }
+
+    @Test
+    fun playWithPointSource_callsRuntimeSoundPoolPlayPointSource() {
+        val expectedStreamId = 1234
+
+        val soundPool = SoundPool.Builder().build()
+        val entity = session.createEntity("test")
+        val pointSourceAttributes = PointSourceAttributes(entity)
+        whenever(
+                mockRtSoundPoolExtensions.play(
+                    eq(soundPool),
+                    any(),
+                    any<JxrPlatformAdapter.PointSourceAttributes>(),
+                    any(),
+                    any(),
+                    any(),
+                    any(),
+                )
+            )
+            .thenReturn(expectedStreamId)
+
+        val actualStreamId =
+            SpatialSoundPool.play(
+                session,
+                soundPool,
+                TEST_SOUND_ID,
+                pointSourceAttributes,
+                TEST_VOLUME,
+                TEST_PRIORITY,
+                TEST_LOOP,
+                TEST_RATE,
+            )
+        verify(mockRtSoundPoolExtensions)
+            .play(
+                eq(soundPool),
+                eq(TEST_SOUND_ID),
+                argWhere<JxrPlatformAdapter.PointSourceAttributes> {
+                    it.entity == mockContentlessEntity
+                },
+                eq(TEST_VOLUME),
+                eq(TEST_PRIORITY),
+                eq(TEST_LOOP),
+                eq(TEST_RATE),
+            )
+        assertThat(actualStreamId).isEqualTo(expectedStreamId)
+    }
+
+    @Test
+    fun playWithSoundField_callsRuntimeSoundPoolPlaySoundField() {
+        val soundPool = SoundPool.Builder().build()
+        val soundFieldAttributes = SoundFieldAttributes(AMBISONICS_ORDER_FIRST_ORDER)
+
+        // TODO(b/317112315): Update test when implementation is finished.
+        assertThat(
+                SpatialSoundPool.play(
+                    session,
+                    soundPool,
+                    TEST_SOUND_ID,
+                    soundFieldAttributes,
+                    TEST_VOLUME,
+                    TEST_PRIORITY,
+                    TEST_LOOP,
+                    TEST_RATE,
+                )
+            )
+            .isEqualTo(0)
+    }
+
+    @Test
+    fun getSourceType_returnsRuntimeSoundPoolGetSourceType() {
+        val expected = SpatializerConstants.SOURCE_TYPE_SOUND_FIELD
+        val soundPool = SoundPool.Builder().build()
+
+        whenever(mockRtSoundPoolExtensions.getSpatialSourceType(any(), any()))
+            .thenReturn(JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_SOUND_FIELD)
+
+        assertThat(SpatialSoundPool.getSpatialSourceType(session, soundPool, TEST_STREAM_ID))
+            .isEqualTo(expected)
+    }
+
+    companion object {
+        const val TEST_SOUND_ID = 0
+        const val TEST_VOLUME = 1F
+        const val TEST_PRIORITY = 0
+        const val TEST_LOOP = 0
+        const val TEST_RATE = 1F
+        const val TEST_STREAM_ID = 10
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialUserTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialUserTest.kt
new file mode 100644
index 0000000..aeec37e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatialUserTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.anyInt
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SpatialUserTest {
+
+    private val mockRuntime = mock<JxrPlatformAdapter>()
+    lateinit var spatialUser: SpatialUser
+
+    @Before
+    fun setUp() {
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        whenever(mockRuntime.getCameraViewActivityPose(anyInt())).thenReturn(mock())
+        spatialUser = SpatialUser.create(mockRuntime)
+    }
+
+    @Test
+    fun getHeadActivityPose_returnsNullIfNoRtActivityPose() {
+        whenever(mockRuntime.headActivityPose).thenReturn(null)
+        val head = spatialUser.head
+        assertThat(head).isNull()
+    }
+
+    @Test
+    fun getHeadActivityPose_returnsNullThenHeadWhenAvailable() {
+        whenever(mockRuntime.headActivityPose).thenReturn(null)
+        var head = spatialUser.head
+        assertThat(head).isNull()
+
+        whenever(mockRuntime.headActivityPose).thenReturn(mock())
+        head = spatialUser.head
+        assertThat(head).isNotNull()
+    }
+
+    @Test
+    fun getHeadActivityPose_returnsHeadActivityPose() {
+        val head = spatialUser.head
+        assertThat(head).isNotNull()
+    }
+
+    @Test
+    fun getHeadActivityPoseTwice_returnsSameHeadActivityPose() {
+        val head1 = spatialUser.head
+        val head2 = spatialUser.head
+
+        assertThat(head1).isEqualTo(head2)
+    }
+
+    @Test
+    fun getNullCameraViews_returnsNullCameraViews() {
+        whenever(mockRuntime.getCameraViewActivityPose(anyInt())).thenReturn(null)
+        val leftView = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        assertThat(leftView).isNull()
+
+        val rightView = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+        assertThat(rightView).isNull()
+    }
+
+    @Test
+    fun getCameraViews_returnsNullThenCameraViewsWhenAvailable() {
+        whenever(mockRuntime.getCameraViewActivityPose(anyInt())).thenReturn(null)
+        var leftView = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        assertThat(leftView).isNull()
+
+        var rightView = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+        assertThat(rightView).isNull()
+
+        whenever(mockRuntime.getCameraViewActivityPose(anyInt())).thenReturn(mock())
+        leftView = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        assertThat(leftView).isNotNull()
+
+        rightView = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+        assertThat(rightView).isNotNull()
+    }
+
+    @Test
+    fun getCameraViews_returnsCameraView() {
+        val leftView = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        assertThat(leftView).isNotNull()
+
+        val rightView = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+        assertThat(rightView).isNotNull()
+    }
+
+    @Test
+    fun getCameraViewsTwice_returnsSameCameraView() {
+        val leftView1 = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        val leftView2 = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+
+        assertThat(leftView1).isEqualTo(leftView2)
+
+        val rightView1 = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+        val rightView2 = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+
+        assertThat(rightView1).isEqualTo(rightView2)
+    }
+
+    @Test
+    fun getCameraViews_returnsCameraViews() {
+        val cameraViews = spatialUser.getCameraViews()
+
+        val leftView = spatialUser.getCameraView(CameraView.CameraType.LEFT_EYE)
+        val rightView = spatialUser.getCameraView(CameraView.CameraType.RIGHT_EYE)
+
+        assertThat(cameraViews).containsExactly(leftView, rightView)
+    }
+
+    @Test
+    fun getCameraViews_returnsEmptyListIfNullCamera() {
+        val mockRuntimeNoCamera = mock<JxrPlatformAdapter>()
+        whenever(mockRuntimeNoCamera.headActivityPose).thenReturn(mock())
+        whenever(mockRuntimeNoCamera.getCameraViewActivityPose(anyInt())).thenReturn(null)
+        val spatialUserNoCamera = SpatialUser.create(mockRuntimeNoCamera)
+
+        val cameraViews = spatialUserNoCamera.getCameraViews()
+
+        assertThat(cameraViews).isEmpty()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatializerConstantsTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatializerConstantsTest.kt
new file mode 100644
index 0000000..a75fc2b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/SpatializerConstantsTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SpatializerConstantsTest {
+
+    @Test
+    fun sourceTypeToJXRExtension_createsCorrectIntDefType() {
+        val rtBypass = JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_BYPASS
+        assertThat(rtBypass.sourceTypeToJxr()).isEqualTo(SpatializerConstants.SOURCE_TYPE_BYPASS)
+
+        val rtPointSource = JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_POINT_SOURCE
+        assertThat(rtPointSource.sourceTypeToJxr())
+            .isEqualTo(SpatializerConstants.SOURCE_TYPE_POINT_SOURCE)
+
+        val rtSoundField = JxrPlatformAdapter.SpatializerConstants.SOURCE_TYPE_SOUND_FIELD
+        assertThat(rtSoundField.sourceTypeToJxr())
+            .isEqualTo(SpatializerConstants.SOURCE_TYPE_SOUND_FIELD)
+    }
+
+    @Test
+    fun ambisonicsOrderToJXR_createCorrectIntDefType() {
+        val rtFirstOrder = JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER
+        assertThat(rtFirstOrder.ambisonicsOrderToJxr())
+            .isEqualTo(SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER)
+
+        val rtSecondOrder = JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER
+        assertThat(rtSecondOrder.ambisonicsOrderToJxr())
+            .isEqualTo(SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER)
+
+        val rtThirdOrder = JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER
+        assertThat(rtThirdOrder.ambisonicsOrderToJxr())
+            .isEqualTo(SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER)
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/UtilsTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/UtilsTest.kt
new file mode 100644
index 0000000..9e03ea2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/UtilsTest.kt
@@ -0,0 +1,563 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore
+
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions as RuntimeDimensions
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity as RuntimeEntity
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent as RuntimeInputEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent.HitInfo as RuntimeHitInfo
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEvent as RuntimeMoveEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions as RuntimePixelDimensions
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent as RuntimeResizeEvent
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities as RuntimeSpatialCapabilities
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+class UtilsTest {
+    @Test
+    fun verifyDefaultPoseIsIdentity() {
+        val rtPose = Pose.Identity
+
+        assertThat(rtPose.translation.x).isZero()
+        assertThat(rtPose.translation.y).isZero()
+        assertThat(rtPose.translation.z).isZero()
+        assertThat(rtPose.rotation.x).isZero()
+        assertThat(rtPose.rotation.y).isZero()
+        assertThat(rtPose.rotation.z).isZero()
+        assertThat(rtPose.rotation.w).isEqualTo(1f)
+    }
+
+    @Test
+    fun verifyPoseToRtPoseConversion() {
+        val rtPose = Pose(Vector3(1f, 2f, 3f), Quaternion(1f, 2f, 3f, 4f).toNormalized())
+
+        assertThat(rtPose.translation.x).isEqualTo(1f)
+        assertThat(rtPose.translation.y).isEqualTo(2f)
+        assertThat(rtPose.translation.z).isEqualTo(3f)
+
+        // The quaternion is always normalized, so the retrieved values differ from the original
+        // ones.
+        assertThat(rtPose.rotation.x).isWithin(1e-5f).of(0.18257418f)
+        assertThat(rtPose.rotation.y).isWithin(1e-5f).of(0.36514837f)
+        assertThat(rtPose.rotation.z).isWithin(1e-5f).of(0.5477225f)
+        assertThat(rtPose.rotation.w).isWithin(1e-5f).of(0.73029673f)
+    }
+
+    @Test
+    fun verifyRtPoseToPoseConversion() {
+        val pose = Pose(Vector3(1f, 2f, 3f), Quaternion(1f, 2f, 3f, 4f).toNormalized())
+
+        assertThat(pose.translation.x).isEqualTo(1f)
+        assertThat(pose.translation.y).isEqualTo(2f)
+        assertThat(pose.translation.z).isEqualTo(3f)
+
+        // The quaternion is always normalized, so the retrieved values differ from the original
+        // ones.
+        assertThat(pose.rotation.x).isWithin(1e-5f).of(0.18257418f)
+        assertThat(pose.rotation.y).isWithin(1e-5f).of(0.36514837f)
+        assertThat(pose.rotation.z).isWithin(1e-5f).of(0.5477225f)
+        assertThat(pose.rotation.w).isWithin(1e-5f).of(0.73029673f)
+    }
+
+    @Test
+    fun verifyRtVector3toVector3() {
+        val vector3: Vector3 = Vector3(1f, 2f, 3f)
+        assertThat(vector3.x).isEqualTo(1f)
+        assertThat(vector3.y).isEqualTo(2f)
+        assertThat(vector3.z).isEqualTo(3f)
+    }
+
+    @Test
+    fun verifyVector3toRtVector3() {
+        val rtVector3: Vector3 = Vector3(1f, 2f, 3f)
+        assertThat(rtVector3.x).isEqualTo(1f)
+        assertThat(rtVector3.y).isEqualTo(2f)
+        assertThat(rtVector3.z).isEqualTo(3f)
+    }
+
+    @Test
+    fun verifyRtMoveEventToMoveEvent() {
+        val vector0 = Vector3(0f, 0f, 0f)
+        val vector1 = Vector3(1f, 1f, 1f)
+        val vector2 = Vector3(2f, 2f, 2f)
+
+        val initialInputRay = JxrPlatformAdapter.Ray(vector0, vector1)
+        val currentInputRay = JxrPlatformAdapter.Ray(vector1, vector2)
+        val entityManager = EntityManager()
+        val activitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+        entityManager.setEntityForRtEntity(activitySpace, mock<Entity>())
+        val moveEvent =
+            RuntimeMoveEvent(
+                    RuntimeMoveEvent.MOVE_STATE_ONGOING,
+                    initialInputRay,
+                    currentInputRay,
+                    Pose(),
+                    Pose(vector1, Quaternion.Identity),
+                    vector1,
+                    vector1,
+                    activitySpace,
+                    null,
+                    null,
+                )
+                .toMoveEvent(entityManager)
+
+        assertThat(moveEvent.moveState).isEqualTo(MoveEvent.MOVE_STATE_ONGOING)
+
+        assertThat(moveEvent.initialInputRay.origin.x).isEqualTo(0f)
+        assertThat(moveEvent.initialInputRay.origin.y).isEqualTo(0f)
+        assertThat(moveEvent.initialInputRay.origin.z).isEqualTo(0f)
+        assertThat(moveEvent.initialInputRay.direction.x).isEqualTo(1f)
+        assertThat(moveEvent.initialInputRay.direction.y).isEqualTo(1f)
+        assertThat(moveEvent.initialInputRay.direction.z).isEqualTo(1f)
+
+        assertThat(moveEvent.currentInputRay.origin.x).isEqualTo(1f)
+        assertThat(moveEvent.currentInputRay.origin.y).isEqualTo(1f)
+        assertThat(moveEvent.currentInputRay.origin.z).isEqualTo(1f)
+        assertThat(moveEvent.currentInputRay.direction.x).isEqualTo(2f)
+        assertThat(moveEvent.currentInputRay.direction.y).isEqualTo(2f)
+        assertThat(moveEvent.currentInputRay.direction.z).isEqualTo(2f)
+
+        assertThat(moveEvent.previousPose.translation.x).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.translation.y).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.translation.z).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.rotation.x).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.rotation.y).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.rotation.z).isEqualTo(0f)
+        assertThat(moveEvent.previousPose.rotation.w).isEqualTo(1f)
+
+        assertThat(moveEvent.currentPose.translation.x).isEqualTo(1f)
+        assertThat(moveEvent.currentPose.translation.y).isEqualTo(1f)
+        assertThat(moveEvent.currentPose.translation.z).isEqualTo(1f)
+        assertThat(moveEvent.currentPose.rotation.x).isEqualTo(0f)
+        assertThat(moveEvent.currentPose.rotation.y).isEqualTo(0f)
+        assertThat(moveEvent.currentPose.rotation.z).isEqualTo(0f)
+        assertThat(moveEvent.currentPose.rotation.w).isEqualTo(1f)
+
+        assertThat(moveEvent.previousScale).isEqualTo(1f)
+
+        assertThat(moveEvent.currentScale).isEqualTo(1f)
+    }
+
+    @Test
+    fun verifyRtInputEventToInputEventConversion() {
+        val entityManager = EntityManager()
+        val activitySpace = mock<JxrPlatformAdapter.ActivitySpace>()
+        entityManager.setEntityForRtEntity(activitySpace, mock<Entity>())
+        val inputEvent =
+            RuntimeInputEvent(
+                    RuntimeInputEvent.SOURCE_HANDS,
+                    RuntimeInputEvent.POINTER_TYPE_LEFT,
+                    123456789,
+                    Vector3(1f, 2f, 3f),
+                    Vector3(4f, 5f, 6f),
+                    RuntimeInputEvent.ACTION_DOWN,
+                    null,
+                    null,
+                )
+                .toInputEvent(entityManager)
+        assertThat(inputEvent.source).isEqualTo(InputEvent.SOURCE_HANDS)
+        assertThat(inputEvent.pointerType).isEqualTo(InputEvent.POINTER_TYPE_LEFT)
+        assertThat(inputEvent.timestamp).isEqualTo(123456789)
+        assertThat(inputEvent.origin.x).isEqualTo(1f)
+        assertThat(inputEvent.origin.y).isEqualTo(2f)
+        assertThat(inputEvent.origin.z).isEqualTo(3f)
+        assertThat(inputEvent.direction.x).isEqualTo(4f)
+        assertThat(inputEvent.direction.y).isEqualTo(5f)
+        assertThat(inputEvent.direction.z).isEqualTo(6f)
+        assertThat(inputEvent.action).isEqualTo(InputEvent.ACTION_DOWN)
+    }
+
+    @Test
+    fun verifyRtHitInfoToHitInfoConversion() {
+        val entityManager = EntityManager()
+        val rtMockEntity = mock<RuntimeEntity>()
+        val mockEntity = mock<Entity>()
+        entityManager.setEntityForRtEntity(rtMockEntity, mockEntity)
+        val hitPosition = Vector3(1f, 2f, 3f)
+        val transform = Matrix4.Identity
+        val hitInfo = RuntimeHitInfo(rtMockEntity, hitPosition, transform).toHitInfo(entityManager)
+
+        assertThat(hitInfo).isNotNull()
+        assertThat(hitInfo!!.inputEntity).isEqualTo(mockEntity)
+        assertThat(hitInfo.hitPosition).isEqualTo(hitPosition)
+        assertThat(hitInfo.transform).isEqualTo(transform)
+    }
+
+    @Test
+    fun verifyRtHitInfoToHitInfoConversionWhenEntityNotFound() {
+        val entityManager = EntityManager()
+        val rtMockEntity = mock<RuntimeEntity>()
+        val hitPosition = Vector3(1f, 2f, 3f)
+        val transform = Matrix4.Identity
+        val hitInfo = RuntimeHitInfo(rtMockEntity, hitPosition, transform).toHitInfo(entityManager)
+
+        // EntityManager does not have the entity for the given RuntimeEntity, so the hit info is
+        // null.
+        assertThat(hitInfo).isNull()
+    }
+
+    @Test
+    fun verifyRtDimensionsToDimensions() {
+        val dimensions: Dimensions = RuntimeDimensions(2f, 4f, 6f).toDimensions()
+        assertThat(dimensions.width).isEqualTo(2f)
+        assertThat(dimensions.height).isEqualTo(4f)
+        assertThat(dimensions.depth).isEqualTo(6f)
+    }
+
+    @Test
+    fun verifyRtPixelDimensionsToPixelDimensions() {
+        val pixelDimensions: PixelDimensions = RuntimePixelDimensions(14, 15).toPixelDimensions()
+        assertThat(pixelDimensions.width).isEqualTo(14)
+        assertThat(pixelDimensions.height).isEqualTo(15)
+    }
+
+    @Test
+    fun verifyPixelDimensionsToRtPixelDimensions() {
+        val pixelDimensions: PixelDimensions = RuntimePixelDimensions(17, 18).toPixelDimensions()
+        assertThat(pixelDimensions.width).isEqualTo(17)
+        assertThat(pixelDimensions.height).isEqualTo(18)
+    }
+
+    @Test
+    fun verifyRuntimeResizeEventToResizeEvent() {
+        val resizeEvent: ResizeEvent =
+            RuntimeResizeEvent(RuntimeResizeEvent.RESIZE_STATE_START, RuntimeDimensions(1f, 3f, 5f))
+                .toResizeEvent()
+        assertThat(resizeEvent.resizeState).isEqualTo(ResizeEvent.RESIZE_STATE_START)
+        assertThat(resizeEvent.newSize.width).isEqualTo(1f)
+        assertThat(resizeEvent.newSize.height).isEqualTo(3f)
+        assertThat(resizeEvent.newSize.depth).isEqualTo(5f)
+    }
+
+    @Test
+    fun runtimeSpatialCapabilitiesToSpatialCapabilities_noCapabilities() {
+        val caps = RuntimeSpatialCapabilities(0).toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isFalse()
+    }
+
+    @Test
+    fun runtimeSpatialCapabilitiesToSpatialCapabilities_singleCapability() {
+        var caps =
+            RuntimeSpatialCapabilities(RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_UI)
+                .toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isFalse()
+
+        caps =
+            RuntimeSpatialCapabilities(RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO)
+                .toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isFalse()
+    }
+
+    @Test
+    fun runtimeSpatialCapabilitiesToSpatialCapabilities_allCapabilities() {
+        val caps =
+            RuntimeSpatialCapabilities(
+                    RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_UI or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY
+                )
+                .toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isTrue()
+    }
+
+    @Test
+    fun runtimeSpatialCapabilitiesToSpatialCapabilities_mixedCapabilities() {
+        var caps =
+            RuntimeSpatialCapabilities(
+                    RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO
+                )
+                .toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isFalse()
+
+        caps =
+            RuntimeSpatialCapabilities(
+                    RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_UI or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT or
+                        RuntimeSpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY
+                )
+                .toSpatialCapabilities()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+            .isTrue()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+            .isFalse()
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+            .isTrue()
+    }
+
+    @Test
+    fun intToMoveState_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.MoveEvent.MOVE_STATE_START,
+                        JxrPlatformAdapter.MoveEvent.MOVE_STATE_ONGOING,
+                        JxrPlatformAdapter.MoveEvent.MOVE_STATE_END,
+                    )
+                    .map { it.toMoveState() }
+            )
+            .containsExactly(
+                MoveEvent.MOVE_STATE_START,
+                MoveEvent.MOVE_STATE_ONGOING,
+                MoveEvent.MOVE_STATE_END,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToMoveState_invalidValue_throwsError() {
+        assertFailsWith<IllegalStateException> { 100.toMoveState() }
+    }
+
+    @Test
+    fun intToResizeState_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_UNKNOWN,
+                        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_START,
+                        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_ONGOING,
+                        JxrPlatformAdapter.ResizeEvent.RESIZE_STATE_END,
+                    )
+                    .map { it.toResizeState() }
+            )
+            .containsExactly(
+                ResizeEvent.RESIZE_STATE_UNKNOWN,
+                ResizeEvent.RESIZE_STATE_START,
+                ResizeEvent.RESIZE_STATE_ONGOING,
+                ResizeEvent.RESIZE_STATE_END,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToResizeState_invalidValue_throwsError() {
+        assertFailsWith<IllegalStateException> { 100.toResizeState() }
+    }
+
+    @Test
+    fun intToInputEventSource_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.InputEvent.SOURCE_UNKNOWN,
+                        JxrPlatformAdapter.InputEvent.SOURCE_HEAD,
+                        JxrPlatformAdapter.InputEvent.SOURCE_CONTROLLER,
+                        JxrPlatformAdapter.InputEvent.SOURCE_HANDS,
+                        JxrPlatformAdapter.InputEvent.SOURCE_MOUSE,
+                        JxrPlatformAdapter.InputEvent.SOURCE_GAZE_AND_GESTURE,
+                    )
+                    .map { it.toInputEventSource() }
+            )
+            .containsExactly(
+                InputEvent.SOURCE_UNKNOWN,
+                InputEvent.SOURCE_HEAD,
+                InputEvent.SOURCE_CONTROLLER,
+                InputEvent.SOURCE_HANDS,
+                InputEvent.SOURCE_MOUSE,
+                InputEvent.SOURCE_GAZE_AND_GESTURE,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToInputEventSource_invalidValue_throwsError() {
+        assertFailsWith<IllegalStateException> { 100.toInputEventSource() }
+    }
+
+    @Test
+    fun intToInputEventPointerType_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.InputEvent.POINTER_TYPE_DEFAULT,
+                        JxrPlatformAdapter.InputEvent.POINTER_TYPE_LEFT,
+                        JxrPlatformAdapter.InputEvent.POINTER_TYPE_RIGHT,
+                    )
+                    .map { it.toInputEventPointerType() }
+            )
+            .containsExactly(
+                InputEvent.POINTER_TYPE_DEFAULT,
+                InputEvent.POINTER_TYPE_LEFT,
+                InputEvent.POINTER_TYPE_RIGHT,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToInputEventPointerType_invalidValue_throwsError() {
+        assertFailsWith<IllegalStateException> { 100.toInputEventPointerType() }
+    }
+
+    @Test
+    fun intToSpatialCapability_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.SpatialCapabilities.SPATIAL_CAPABILITY_UI,
+                        JxrPlatformAdapter.SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT,
+                        JxrPlatformAdapter.SpatialCapabilities
+                            .SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL,
+                        JxrPlatformAdapter.SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT,
+                        JxrPlatformAdapter.SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO,
+                        JxrPlatformAdapter.SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY,
+                    )
+                    .map { it.toSpatialCapability() }
+            )
+            .containsExactly(
+                SpatialCapabilities.SPATIAL_CAPABILITY_UI,
+                SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT,
+                SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL,
+                SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT,
+                SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO,
+                SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToInputEventAction_convertsCorrectly() {
+        assertThat(
+                listOf(
+                        JxrPlatformAdapter.InputEvent.ACTION_DOWN,
+                        JxrPlatformAdapter.InputEvent.ACTION_UP,
+                        JxrPlatformAdapter.InputEvent.ACTION_MOVE,
+                        JxrPlatformAdapter.InputEvent.ACTION_CANCEL,
+                        JxrPlatformAdapter.InputEvent.ACTION_HOVER_MOVE,
+                        JxrPlatformAdapter.InputEvent.ACTION_HOVER_ENTER,
+                        JxrPlatformAdapter.InputEvent.ACTION_HOVER_EXIT,
+                    )
+                    .map { it.toInputEventAction() }
+            )
+            .containsExactly(
+                InputEvent.ACTION_DOWN,
+                InputEvent.ACTION_UP,
+                InputEvent.ACTION_MOVE,
+                InputEvent.ACTION_CANCEL,
+                InputEvent.ACTION_HOVER_MOVE,
+                InputEvent.ACTION_HOVER_ENTER,
+                InputEvent.ACTION_HOVER_EXIT,
+            )
+            .inOrder()
+    }
+
+    @Test
+    fun intToInputEventAction_invalidValue_throwsError() {
+        assertFailsWith<IllegalStateException> { 100.toInputEventAction() }
+    }
+
+    @Test
+    fun anchorPlacementToRuntimeAnchorPlacement_setsCorrectly() {
+        val mockRuntime = mock<JxrPlatformAdapter>()
+        val mockAnchorPlacement1 = mock<JxrPlatformAdapter.AnchorPlacement>()
+        val mockAnchorPlacement2 = mock<JxrPlatformAdapter.AnchorPlacement>()
+        whenever(
+                mockRuntime.createAnchorPlacementForPlanes(
+                    setOf(JxrPlatformAdapter.PlaneType.HORIZONTAL),
+                    setOf(JxrPlatformAdapter.PlaneSemantic.ANY),
+                )
+            )
+            .thenReturn(mockAnchorPlacement1)
+        whenever(
+                mockRuntime.createAnchorPlacementForPlanes(
+                    setOf(JxrPlatformAdapter.PlaneType.ANY),
+                    setOf(
+                        JxrPlatformAdapter.PlaneSemantic.WALL,
+                        JxrPlatformAdapter.PlaneSemantic.FLOOR
+                    ),
+                )
+            )
+            .thenReturn(mockAnchorPlacement2)
+
+        val anchorPlacement1 =
+            AnchorPlacement.createForPlanes(planeTypeFilter = setOf(PlaneType.HORIZONTAL))
+        val anchorPlacement2 =
+            AnchorPlacement.createForPlanes(
+                planeSemanticFilter = setOf(PlaneSemantic.WALL, PlaneSemantic.FLOOR)
+            )
+
+        val rtPlacementSet =
+            setOf(anchorPlacement1, anchorPlacement2).toRtAnchorPlacement(mockRuntime)
+
+        assertThat(rtPlacementSet.size).isEqualTo(2)
+        assertThat(rtPlacementSet).containsExactly(mockAnchorPlacement1, mockAnchorPlacement2)
+    }
+
+    @Test
+    fun anchorPlacementToRuntimeAnchotPlacementEmptySet_returnsEmptySet() {
+        val mockRuntime = mock<JxrPlatformAdapter>()
+
+        val rtPlacementSet = emptySet<AnchorPlacement>().toRtAnchorPlacement(mockRuntime)
+
+        assertThat(rtPlacementSet).isEmpty()
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivityPanelEntityImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivityPanelEntityImplTest.java
new file mode 100644
index 0000000..d7c711b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivityPanelEntityImplTest.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Rect;
+
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivityPanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeActivityPanel;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+@RunWith(RobolectricTestRunner.class)
+public class ActivityPanelEntityImplTest {
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity hostActivity = activityController.create().start().get();
+    private final PixelDimensions windowBoundsPx = new PixelDimensions(640, 480);
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = Mockito.mock(PerceptionLibrary.class);
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    private final ImpSplitEngineRenderer splitEngineRenderer =
+            Mockito.mock(ImpSplitEngineRenderer.class);
+
+    private ActivityPanelEntity createActivityPanelEntity() {
+        when(perceptionLibrary.initSession(eq(hostActivity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(Mockito.mock(Session.class)));
+
+        JxrPlatformAdapter fakeRuntime =
+                JxrPlatformAdapterAxr.create(
+                        hostActivity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+        Pose pose = new Pose();
+
+        return fakeRuntime.createActivityPanelEntity(
+                pose, windowBoundsPx, "test", hostActivity, fakeRuntime.getActivitySpaceRootImpl());
+    }
+
+    @Test
+    public void createActivityPanelEntity_returnsActivityPanelEntity() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        assertThat(activityPanelEntity).isNotNull();
+    }
+
+    @Test
+    public void activityPanelEntityLaunchActivity_callsActivityPanel() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+        Intent launchIntent = activityController.getIntent();
+        activityPanelEntity.launchActivity(launchIntent, null);
+
+        FakeActivityPanel fakePanel = fakeExtensions.getActivityPanelForHost(hostActivity);
+
+        assertThat(fakePanel.getLaunchIntent()).isEqualTo(launchIntent);
+        assertThat(fakePanel.getBundle()).isNull();
+        assertThat(fakePanel.getBounds())
+                .isEqualTo(new Rect(0, 0, windowBoundsPx.width, windowBoundsPx.height));
+    }
+
+    @Test
+    public void activityPanelEntityMoveActivity_callActivityPanel() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+        activityPanelEntity.moveActivity(hostActivity);
+
+        FakeActivityPanel fakePanel = fakeExtensions.getActivityPanelForHost(hostActivity);
+
+        assertThat(fakePanel.getActivity()).isEqualTo(hostActivity);
+
+        assertThat(fakePanel.getBounds())
+                .isEqualTo(new Rect(0, 0, windowBoundsPx.width, windowBoundsPx.height));
+    }
+
+    @Test
+    public void activityPanelEntitySetSize_callsSetPixelDimensions() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+        Dimensions dimensions = new Dimensions(400f, 300f, 0f);
+        activityPanelEntity.setSize(dimensions);
+
+        FakeActivityPanel fakePanel = fakeExtensions.getActivityPanelForHost(hostActivity);
+
+        assertThat(fakePanel.getBounds())
+                .isEqualTo(new Rect(0, 0, (int) dimensions.width, (int) dimensions.height));
+
+        // SetSize redirects to setPixelDimensions, so we check the same thing here.
+        PixelDimensions viewDimensions = activityPanelEntity.getPixelDimensions();
+        assertThat(viewDimensions.width).isEqualTo((int) dimensions.width);
+        assertThat(viewDimensions.height).isEqualTo((int) dimensions.height);
+    }
+
+    @Test
+    public void activityPanelEntitySetPixelDimensions_callActivityPanel() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+        PixelDimensions dimensions = new PixelDimensions(400, 300);
+        activityPanelEntity.setPixelDimensions(dimensions);
+
+        FakeActivityPanel fakePanel = fakeExtensions.getActivityPanelForHost(hostActivity);
+
+        assertThat(fakePanel.getBounds())
+                .isEqualTo(new Rect(0, 0, dimensions.width, dimensions.height));
+
+        PixelDimensions viewDimensions = activityPanelEntity.getPixelDimensions();
+        assertThat(viewDimensions.width).isEqualTo(dimensions.width);
+        assertThat(viewDimensions.height).isEqualTo(dimensions.height);
+    }
+
+    @Test
+    public void activityPanelEntityDispose_callsActivityPanelDelete() {
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+        activityPanelEntity.dispose();
+
+        FakeActivityPanel fakePanel = fakeExtensions.getActivityPanelForHost(hostActivity);
+
+        assertThat(fakePanel.isDeleted()).isTrue();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivitySpaceImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivitySpaceImplTest.java
new file mode 100644
index 0000000..5238b8fb
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ActivitySpaceImplTest.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+
+import androidx.xr.extensions.space.Bounds;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialState;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ActivitySpaceImplTest extends SystemSpaceEntityImplTest {
+    // TODO(b/329902726): Move this boilerplate for creating a TestJxrPlatformAdapter into a test
+    // util
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity activity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = Mockito.mock(PerceptionLibrary.class);
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    private final ImpSplitEngineRenderer splitEngineRenderer =
+            Mockito.mock(ImpSplitEngineRenderer.class);
+
+    private FakeXrExtensions fakeExtensions;
+    private FakeImpressApi fakeImpressApi;
+    private JxrPlatformAdapter testRuntime;
+    private ActivitySpace activitySpace;
+
+    @Before
+    public void setUp() {
+        fakeExtensions = new FakeXrExtensions();
+        fakeImpressApi = new FakeImpressApi();
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(Mockito.mock(Session.class)));
+
+        testRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        activitySpace = testRuntime.getActivitySpace();
+
+        // This is slightly hacky. We're grabbing the singleton instance of the ActivitySpaceImpl
+        // that
+        // was created by the RuntimeImpl. Ideally we'd have an interface to inject the
+        // ActivitySpace
+        // for testing.  For now this is fine since there isn't an interface difference (yet).
+        assertThat(activitySpace).isInstanceOf(ActivitySpaceImpl.class);
+        assertThat(activitySpace).isNotNull();
+    }
+
+    @Override
+    protected SystemSpaceEntityImpl getSystemSpaceEntityImpl() {
+        return (SystemSpaceEntityImpl) activitySpace;
+    }
+
+    @Override
+    protected FakeScheduledExecutorService getDefaultFakeExecutor() {
+        return fakeExecutor;
+    }
+
+    @Override
+    protected AndroidXrEntity createChildAndroidXrEntity() {
+        return (AndroidXrEntity) testRuntime.createEntity(new Pose(), "child", activitySpace);
+    }
+
+    @Override
+    protected ActivitySpaceImpl getActivitySpaceEntity() {
+        return (ActivitySpaceImpl) activitySpace;
+    }
+
+    @Test
+    public void getBounds_returnsBounds() {
+        assertThat(activitySpace.getBounds().width).isPositiveInfinity();
+        assertThat(activitySpace.getBounds().height).isPositiveInfinity();
+        assertThat(activitySpace.getBounds().depth).isPositiveInfinity();
+
+        FakeSpatialState spatialState = new FakeSpatialState();
+        spatialState.setBounds(new Bounds(100.0f, 200.0f, 300.0f));
+        fakeExtensions.sendSpatialState(spatialState);
+
+        assertThat(activitySpace.getBounds().width).isEqualTo(100f);
+        assertThat(activitySpace.getBounds().height).isEqualTo(200f);
+        assertThat(activitySpace.getBounds().depth).isEqualTo(300f);
+    }
+
+    @Test
+    public void addBoundsChangedListener_happyPath() {
+        JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener listener =
+                Mockito.mock(JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener.class);
+
+        FakeSpatialState spatialState = new FakeSpatialState();
+        spatialState.setBounds(new Bounds(100.0f, 200.0f, 300.0f));
+        activitySpace.addOnBoundsChangedListener(listener);
+        fakeExtensions.sendSpatialState(spatialState);
+
+        verify(listener).onBoundsChanged(Mockito.refEq(new Dimensions(100.0f, 200.0f, 300.0f)));
+    }
+
+    @Test
+    public void removeBoundsChangedListener_happyPath() {
+        JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener listener =
+                Mockito.mock(JxrPlatformAdapter.ActivitySpace.OnBoundsChangedListener.class);
+
+        activitySpace.addOnBoundsChangedListener(listener);
+        activitySpace.removeOnBoundsChangedListener(listener);
+        FakeSpatialState spatialState = new FakeSpatialState();
+        spatialState.setBounds(new Bounds(100.0f, 200.0f, 300.0f));
+        fakeExtensions.sendSpatialState(spatialState);
+
+        verify(listener, Mockito.never()).onBoundsChanged(Mockito.any());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_returnsIdentity() {
+        ActivitySpaceImpl activitySpaceImpl = (ActivitySpaceImpl) activitySpace;
+
+        assertPose(activitySpaceImpl.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getActivitySpaceScale_returnsUnitScale() {
+        ActivitySpaceImpl activitySpaceImpl = (ActivitySpaceImpl) activitySpace;
+        activitySpaceImpl.setOpenXrReferenceSpacePose(Matrix4.fromScale(5f));
+        assertVector3(activitySpaceImpl.getActivitySpaceScale(), new Vector3(1f, 1f, 1f));
+    }
+
+    @Test
+    public void setScale_doesNothing() throws Exception {
+        Vector3 scale = new Vector3(1, 1, 9999);
+        activitySpace.setScale(scale);
+
+        // The returned scale(s) here should be the identity scale despite the setScale call.
+        assertThat(activitySpace.getScale().getX()).isWithin(1e-5f).of(1.0f);
+        assertThat(activitySpace.getScale().getY()).isWithin(1e-5f).of(1.0f);
+        assertThat(activitySpace.getScale().getZ()).isWithin(1e-5f).of(1.0f);
+
+        // Note that there's no exception thrown.
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AnchorEntityImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AnchorEntityImplTest.java
new file mode 100644
index 0000000..84b93bb
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AnchorEntityImplTest.java
@@ -0,0 +1,1247 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.os.IBinder;
+import android.os.SystemClock;
+
+import androidx.xr.extensions.node.Node;
+import androidx.xr.runtime.internal.Anchor.PersistenceState;
+import androidx.xr.runtime.internal.TrackingState;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.runtime.openxr.ExportableAnchor;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.OnStateChangedListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistState;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.PersistStateChangeListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.impl.perception.Anchor;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeGltfModelToken;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+import java.time.Duration;
+import java.util.UUID;
+
+@RunWith(RobolectricTestRunner.class)
+public final class AnchorEntityImplTest extends SystemSpaceEntityImplTest {
+    private static class FakeExportableAnchor implements ExportableAnchor {
+        private final long nativePointer;
+        private final IBinder anchorToken;
+        private final Pose pose;
+        private final TrackingState trackingState;
+        private final PersistenceState persistenceState;
+        private final UUID uuid;
+
+        public FakeExportableAnchor(
+                long nativePointer,
+                IBinder anchorToken,
+                Pose pose,
+                TrackingState trackingState,
+                PersistenceState persistenceState,
+                UUID uuid) {
+            this.nativePointer = nativePointer;
+            this.anchorToken = anchorToken;
+            this.pose = pose;
+            this.trackingState = trackingState;
+            this.persistenceState = persistenceState;
+            this.uuid = uuid;
+        }
+
+        @Override
+        public long getNativePointer() {
+            return nativePointer;
+        }
+
+        @Override
+        public IBinder getAnchorToken() {
+            return anchorToken;
+        }
+
+        @Override
+        public Pose getPose() {
+            return pose;
+        }
+
+        @Override
+        public TrackingState getTrackingState() {
+            return trackingState;
+        }
+
+        @Override
+        public PersistenceState getPersistenceState() {
+            return persistenceState;
+        }
+
+        @Override
+        public UUID getUuid() {
+            return uuid;
+        }
+
+        @Override
+        public void detach() {}
+
+        @Override
+        public void persist() {}
+    }
+
+    private static final Dimensions ANCHOR_DIMENSIONS = new Dimensions(2f, 5f, 0f);
+    private static final Plane.Type PLANE_TYPE = Plane.Type.VERTICAL;
+    private static final Plane.Label PLANE_LABEL = Plane.Label.WALL;
+    private static final long NATIVE_POINTER = 1234567890L;
+    private final AndroidXrEntity activitySpaceRoot = Mockito.mock(AndroidXrEntity.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final PerceptionLibrary perceptionLibrary = Mockito.mock(PerceptionLibrary.class);
+    private final Session session = Mockito.mock(Session.class);
+    private final Plane plane = mock(Plane.class);
+    private final Anchor anchor = Mockito.mock(Anchor.class);
+    private final OnStateChangedListener anchorStateListener =
+            Mockito.mock(OnStateChangedListener.class);
+    private final IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+    private final FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+    private final EntityManager entityManager = new EntityManager();
+    private final PersistStateChangeListener persistStateChangeListener =
+            Mockito.mock(PersistStateChangeListener.class);
+    private final androidx.xr.scenecore.impl.perception.Pose perceptionIdentityPose =
+            androidx.xr.scenecore.impl.perception.Pose.identity();
+    private long currentTimeMillis = 1000000000L;
+    private ActivitySpaceImpl activitySpace;
+
+    @Before
+    public void doBeforeEachTest() {
+        Node taskNode = fakeExtensions.createNode();
+        this.activitySpace =
+                new ActivitySpaceImpl(
+                        taskNode,
+                        fakeExtensions,
+                        entityManager,
+                        () -> fakeExtensions.fakeSpatialState,
+                        executor);
+        SystemClock.setCurrentTimeMillis(currentTimeMillis);
+
+        // By default, set the activity space to the root of the underlying OpenXR reference space.
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+    }
+
+    /**
+     * Returns the anchor entity impl. Used in the base SystemSpaceEntityImplTest to ensure that the
+     * anchor entity complies with all the expected behaviors of a system space entity.
+     */
+    @Override
+    protected SystemSpaceEntityImpl getSystemSpaceEntityImpl() {
+        return createSemanticAnchorEntity();
+    }
+
+    @Override
+    protected FakeScheduledExecutorService getDefaultFakeExecutor() {
+        return executor;
+    }
+
+    @Override
+    protected AndroidXrEntity createChildAndroidXrEntity() {
+        return createGltfEntity();
+    }
+
+    @Override
+    protected ActivitySpaceImpl getActivitySpaceEntity() {
+        return this.activitySpace;
+    }
+
+    // Advances the clock and the executor. The fake executor is not based on the clock because we
+    // are
+    // using the SystemClock but they can be advanced together.
+    void advanceClock(Duration duration) {
+        currentTimeMillis += duration.toMillis();
+        SystemClock.setCurrentTimeMillis(currentTimeMillis);
+        executor.simulateSleepExecutingAllTasks(duration);
+    }
+
+    /** Creates an AnchorEntityImpl instance. */
+    private AnchorEntityImpl createSemanticAnchorEntity() {
+        return createAnchorEntityWithTimeout(null);
+    }
+
+    /** Creates an AnchorEntityImpl instance with a timeout. */
+    private AnchorEntityImpl createAnchorEntityWithTimeout(Duration anchorSearchTimeout) {
+        Node node = fakeExtensions.createNode();
+        return AnchorEntityImpl.createSemanticAnchor(
+                node,
+                ANCHOR_DIMENSIONS,
+                PlaneType.VERTICAL,
+                PlaneSemantic.WALL,
+                anchorSearchTimeout,
+                activitySpace,
+                activitySpaceRoot,
+                fakeExtensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    /**
+     * Creates an AnchorEntityImpl instance that will search for a persisted anchor within
+     * `anchorSearchTimeout`.
+     */
+    private AnchorEntityImpl createPersistedAnchorEntityWithTimeout(
+            UUID uuid, Duration anchorSearchTimeout) {
+        Node node = fakeExtensions.createNode();
+        return AnchorEntityImpl.createPersistedAnchor(
+                node,
+                uuid,
+                anchorSearchTimeout,
+                activitySpace,
+                activitySpaceRoot,
+                fakeExtensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    /** Creates an AnchorEntityImpl instance and initializes it with a persisted anchor. */
+    private AnchorEntityImpl createInitializedPersistedAnchorEntity(Anchor anchor, UUID uuid) {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(uuid)).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        Node node = fakeExtensions.createNode();
+        return AnchorEntityImpl.createPersistedAnchor(
+                node,
+                uuid,
+                /* anchorSearchTimeout= */ null,
+                activitySpace,
+                activitySpaceRoot,
+                fakeExtensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    /**
+     * Creates an AnchorEntityImpl and initializes it with an anchor from the perception library.
+     */
+    private AnchorEntityImpl createAndInitAnchorEntity() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        return createSemanticAnchorEntity();
+    }
+
+    private AnchorEntityImpl createInitAndPersistAnchorEntity() {
+        when(anchor.persist()).thenReturn(UUID.randomUUID());
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        anchorEntity.registerPersistStateChangeListener(persistStateChangeListener);
+        UUID unused = anchorEntity.persist();
+        return anchorEntity;
+    }
+
+    private AnchorEntityImpl createAnchorEntityFromPlane() {
+        when(anchor.persist()).thenReturn(UUID.randomUUID());
+
+        Node node = fakeExtensions.createNode();
+        return AnchorEntityImpl.createAnchorFromPlane(
+                node,
+                plane,
+                new Pose(),
+                MILLISECONDS.toNanos(currentTimeMillis),
+                activitySpace,
+                activitySpaceRoot,
+                fakeExtensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    private AnchorEntityImpl createAnchorEntityFromPerceptionAnchor(
+            androidx.xr.arcore.Anchor perceptionAnchor) {
+        Node node = fakeExtensions.createNode();
+
+        return AnchorEntityImpl.createAnchorFromPerceptionAnchor(
+                node,
+                perceptionAnchor,
+                activitySpace,
+                activitySpaceRoot,
+                fakeExtensions,
+                entityManager,
+                executor,
+                perceptionLibrary);
+    }
+
+    /** Creates a generic glTF entity. */
+    private GltfEntityImpl createGltfEntity() {
+        FakeGltfModelToken modelToken = new FakeGltfModelToken("model");
+        GltfModelResourceImpl model = new GltfModelResourceImpl(modelToken);
+        return new GltfEntityImpl(model, activitySpace, fakeExtensions, entityManager, executor);
+    }
+
+    @Test
+    public void createAnchorEntityWithPersistedAnchor_returnsAnchored() throws Exception {
+        AnchorEntityImpl anchorEntity =
+                createInitializedPersistedAnchorEntity(anchor, UUID.randomUUID());
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+        assertThat(((FakeNode) anchorEntity.getNode()).getAnchorId()).isEqualTo(sharedAnchorToken);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+    }
+
+    @Test
+    public void createAnchorEntityWithPersistedAnchor_persistAnchor_returnsUuid() throws Exception {
+        UUID uuid = UUID.randomUUID();
+        AnchorEntityImpl anchorEntity = createInitializedPersistedAnchorEntity(anchor, uuid);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+        assertThat(((FakeNode) anchorEntity.getNode()).getAnchorId()).isEqualTo(sharedAnchorToken);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+
+        UUID returnedUuid = anchorEntity.persist();
+        assertThat(returnedUuid).isEqualTo(uuid);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_sessionNotReady_keepUnanchored() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(
+                        UUID.randomUUID(), /* anchorSearchTimeout= */ null);
+
+        // if the session isn't ready, we should retry later and search for the anchor.
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_persistedAnchorNotFound_keepUnanchored()
+            throws Exception {
+        UUID uuid = UUID.randomUUID();
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(uuid)).thenReturn(null);
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(uuid, /* anchorSearchTimeout= */ null);
+
+        // If the session is ready and we can't find it, the perception stack might be warming up,
+        // so
+        // we'll retry later.
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_persistedAnchorHasNoToken_keepUnanchored()
+            throws Exception {
+        UUID uuid = UUID.randomUUID();
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(uuid)).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(null);
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(uuid, /* anchorSearchTimeout= */ null);
+
+        // If the anchor is ready but its token isn't available, we'll retry later.
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_delayedSession_callsCallback() throws Exception {
+        // This will return an error on the first attempt so will need to be called twice.
+        when(perceptionLibrary.getSession()).thenReturn(null).thenReturn(session);
+        UUID uuid = UUID.randomUUID();
+        when(session.createAnchorFromUuid(uuid)).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(uuid, /* anchorSearchTimeout= */ null);
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // The anchor starts as unanchored. Advance the executor to try again successfully and get a
+        // callback for the anchor to be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_delayedAnchor_callsCallback() throws Exception {
+        // This will return an error on the first attempt so will need to be called twice.
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        UUID uuid = UUID.randomUUID();
+        when(session.createAnchorFromUuid(uuid)).thenReturn(anchor).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(null).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(uuid, /* anchorSearchTimeout= */ null);
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // The anchor starts as unanchored. Advance the executor to try again successfully and get a
+        // callback for the anchor to be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_delayedSession_timeout_noCallback() throws Exception {
+        // This will return an error on the first attempt so will need to be called twice.
+        when(perceptionLibrary.getSession()).thenReturn(null).thenReturn(session);
+        UUID uuid = UUID.randomUUID();
+        when(session.createAnchorFromUuid(uuid)).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity =
+                createPersistedAnchorEntityWithTimeout(
+                        uuid, AnchorEntityImpl.ANCHOR_SEARCH_DELAY.dividedBy(2));
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        verify(anchorStateListener).onStateChanged(State.TIMED_OUT);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.TIMED_OUT);
+    }
+
+    @Test
+    public void createAnchorEntity_defaultUnanchored() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+    }
+
+    @Test
+    public void createAndInitAnchor_returnsAnchored() throws Exception {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+        assertThat(((FakeNode) anchorEntity.getNode()).getAnchorId()).isEqualTo(sharedAnchorToken);
+    }
+
+    @Test
+    public void createAndInitAnchor_noPlanes_remainsUnanchored() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of());
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+    }
+
+    @Test
+    public void createAndInitAnchor_noViablePlane_remainsUnanchored() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width - 1,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+    }
+
+    @Test
+    public void createAndInitAnchor_delayedSession_callsCallback() throws Exception {
+        // This will return an error on the first attempt so will need to be called twice.
+        when(perceptionLibrary.getSession()).thenReturn(null).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // The anchor starts as unanchored. Advance the executor to try again successfully and get a
+        // callback for the anchor to be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+    }
+
+    @Test
+    public void createAndInitAnchor_delayedAnchor_callsCallback() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        // This will return no planes on the first attempt so will need to be called twice.
+        when(session.getAllPlanes())
+                .thenReturn(ImmutableList.of())
+                .thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // The anchor starts as unanchored. Advance the executor to try again successfully and get a
+        // callback for the anchor to be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+    }
+
+    @Test
+    public void createAndInitAnchor_delayedSessionAndAnchor_callsCallback() throws Exception {
+        // This will return an error on the first attempt then it will find no planes on the
+        // second attempt. So it will need to be called three times.
+        when(perceptionLibrary.getSession())
+                .thenReturn(null)
+                .thenReturn(session)
+                .thenReturn(session);
+        when(session.getAllPlanes())
+                .thenReturn(ImmutableList.of())
+                .thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // Advance the executor to try again with a working session but an error on the anchor.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // Advance the executor attempt the anchor again successfully and get a callback for the
+        // anchor
+        // to be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+    }
+
+    @Test
+    public void createAndInitAnchor_noToken_returnsUnanchored() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(null);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+    }
+
+    @Test
+    public void createAndInitAnchor_withinTimeout_success() throws Exception {
+        // The anchor creation will return an error so that it is called again on the executor. The
+        // timeout will happen after the second attempt so it will still succeed.
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes())
+                .thenReturn(ImmutableList.of())
+                .thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create an anchor entity with a timeout of 1ms above the normal search delay.
+        Duration timeout = AnchorEntityImpl.ANCHOR_SEARCH_DELAY.plusMillis(1);
+        AnchorEntityImpl anchorEntity = createAnchorEntityWithTimeout(timeout);
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // Advance the executor to try again it should now be anchored.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(anchorStateListener).onStateChanged(State.ANCHORED);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+
+        // Advance the clock past the timeout and verify that nothing changed.
+        advanceClock(timeout.minus(AnchorEntityImpl.ANCHOR_SEARCH_DELAY));
+        verify(anchorStateListener, never()).onStateChanged(State.TIMED_OUT);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+    }
+
+    @Test
+    public void createAndInitAnchor_passedTimeout_error() throws Exception {
+        // The anchor creation will return an error so that it is called again on the executor. The
+        // timeout will happen before the next call.
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of());
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create an anchor entity with a timeout of 1ms below the normal search delay.
+        Duration timeout = AnchorEntityImpl.ANCHOR_SEARCH_DELAY.minusMillis(1);
+        AnchorEntityImpl anchorEntity = createAnchorEntityWithTimeout(timeout);
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(anchorStateListener, never()).onStateChanged(State.ERROR);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // Advance the executor to try again it should now be timed out.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        assertThat(anchorEntity.getState()).isEqualTo(State.TIMED_OUT);
+
+        // Advance the clock again and verify that nothing changed.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        assertThat(anchorEntity.getState()).isEqualTo(State.TIMED_OUT);
+    }
+
+    @Test
+    public void createAndInitAnchor_zeroTimeout_keepsSearching() throws Exception {
+        // The anchor creation will return an error so that it is called again on the executor. The
+        // timeout will happen after the second attempt so it will still succeed.
+        int anchorAttempts = 100;
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of());
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create an anchor entity with a zero duration it should be search for indefinitely..
+        AnchorEntityImpl anchorEntity = createAnchorEntityWithTimeout(Duration.ZERO);
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        for (int i = 0; i < anchorAttempts - 1; i++) {
+            advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+            verify(anchorStateListener, never()).onStateChanged(any());
+            assertThat(anchorEntity).isNotNull();
+            assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+        }
+    }
+
+    @Test
+    public void anchorEntityAddChildren_addsChildren() throws Exception {
+        GltfEntityImpl childEntity1 = createGltfEntity();
+        GltfEntityImpl childEntity2 = createGltfEntity();
+        AnchorEntityImpl parentEntity = createSemanticAnchorEntity();
+
+        parentEntity.addChild(childEntity1);
+
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1);
+
+        parentEntity.addChildren(ImmutableList.of(childEntity2));
+
+        assertThat(childEntity1.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity2.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1, childEntity2);
+
+        FakeNode parentNode = (FakeNode) parentEntity.getNode();
+        FakeNode childNode1 = (FakeNode) childEntity1.getNode();
+        FakeNode childNode2 = (FakeNode) childEntity2.getNode();
+
+        assertThat(childNode1.getParent()).isEqualTo(parentNode);
+        assertThat(childNode2.getParent()).isEqualTo(parentNode);
+    }
+
+    @Test
+    public void anchorEntitySetPose_throwsException() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        Pose pose = new Pose();
+        assertThrows(UnsupportedOperationException.class, () -> anchorEntity.setPose(pose));
+    }
+
+    @Test
+    public void anchorEntitySetScale_throwsException() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        Vector3 scale = new Vector3(1, 1, 1);
+        assertThrows(UnsupportedOperationException.class, () -> anchorEntity.setScale(scale));
+    }
+
+    @Test
+    public void anchorEntityGetScale_returnsIdentityScale() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertVector3(anchorEntity.getScale(), new Vector3(1f, 1f, 1f));
+    }
+
+    @Test
+    public void anchorEntityGetWorldSpaceScale_returnsIdentityScale() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertVector3(anchorEntity.getWorldSpaceScale(), new Vector3(1f, 1f, 1f));
+    }
+
+    @Test
+    public void anchorEntityGetActivitySpaceScale_returnsInverseOfActivitySpace() throws Exception {
+        float activitySpaceScale = 5f;
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromScale(activitySpaceScale));
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertVector3(
+                anchorEntity.getActivitySpaceScale(),
+                new Vector3(1f, 1f, 1f).div(activitySpaceScale));
+    }
+
+    @Test
+    public void getPoseInActivitySpace_unanchored_returnsIdentityPose() {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(anchorEntity.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void
+            getPoseInActivitySpace_noActivitySpaceOpenXrReferenceSpacePose_returnsIdentityPose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        activitySpace.openXrReferenceSpacePose = null;
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+        assertPose(anchorEntity.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_noAnchorOpenXrReferenceSpacePose_returnsIdentityPose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+        // anchorEntity.setOpenXrReferenceSpacePose(..) is not called to set the underlying pose.
+
+        assertPose(anchorEntity.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_whenAtSamePose_returnsIdentityPose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(anchorEntity.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_returnsDifferencePose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(anchorEntity.getPoseInActivitySpace(), pose);
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withNoActivitySpace_throwsException() throws Exception {
+        Node node = fakeExtensions.createNode();
+        AnchorEntityImpl anchorEntity =
+                AnchorEntityImpl.createSemanticAnchor(
+                        node,
+                        /* dimensions= */ null,
+                        /* planeType= */ null,
+                        /* planeSemantic= */ null,
+                        /* anchorSearchTimeout= */ null,
+                        /* activitySpace= */ null,
+                        activitySpaceRoot,
+                        fakeExtensions,
+                        entityManager,
+                        executor,
+                        perceptionLibrary);
+
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+        assertThrows(IllegalStateException.class, anchorEntity::getPoseInActivitySpace);
+    }
+
+    @Test
+    public void getActivitySpacePose_whenAtSamePose_returnsIdentityPose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        when(activitySpaceRoot.getPoseInActivitySpace()).thenReturn(pose);
+
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(anchorEntity.getActivitySpacePose(), new Pose());
+    }
+
+    @Test
+    public void getActivitySpacePose_returnsDifferencePose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        when(activitySpaceRoot.getPoseInActivitySpace()).thenReturn(new Pose());
+
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(anchorEntity.getActivitySpacePose(), pose);
+    }
+
+    @Test
+    public void getActivitySpacePose_withNonAndroidXrActivitySpaceRoot_throwsException()
+            throws Exception {
+        Node node = fakeExtensions.createNode();
+        AnchorEntityImpl anchorEntity =
+                AnchorEntityImpl.createSemanticAnchor(
+                        node,
+                        /* dimensions= */ null,
+                        /* planeType= */ null,
+                        /* planeSemantic= */ null,
+                        /* anchorSearchTimeout= */ null,
+                        activitySpace,
+                        /* activitySpaceRoot= */ null,
+                        fakeExtensions,
+                        entityManager,
+                        executor,
+                        perceptionLibrary);
+
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+        assertThrows(IllegalStateException.class, anchorEntity::getActivitySpacePose);
+    }
+
+    @Test
+    public void transformPoseTo_withActivitySpace_returnsTransformedPose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        Pose anchorOffset =
+                new Pose(
+                        new Vector3(10f, 0f, 0f),
+                        Quaternion.fromEulerAngles(new Vector3(0f, 0f, 90f)));
+        Pose transformedPose = anchorEntity.transformPoseTo(anchorOffset, activitySpace);
+
+        assertPose(
+                transformedPose,
+                new Pose(
+                        new Vector3(11f, 2f, 3f),
+                        Quaternion.fromEulerAngles(new Vector3(0f, 0f, 90f))));
+    }
+
+    @Test
+    public void transformPoseTo_fromActivitySpaceChild_returnsAnchorSpacePose() {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        GltfEntityImpl childEntity1 = createGltfEntity();
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+        Pose childPose = new Pose(new Vector3(-1f, -2f, -3f), Quaternion.Identity);
+
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        activitySpace.addChild(childEntity1);
+        childEntity1.setPose(childPose);
+        anchorEntity.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(
+                activitySpace.transformPoseTo(new Pose(), anchorEntity),
+                new Pose(new Vector3(-1f, -2f, -3f), Quaternion.Identity));
+
+        Pose transformedPose = childEntity1.transformPoseTo(new Pose(), anchorEntity);
+        assertPose(transformedPose, new Pose(new Vector3(-2f, -4f, -6f), Quaternion.Identity));
+    }
+
+    @Test
+    public void anchorEntity_setsParentAfterAnchoring() throws Exception {
+        // This will return an error on the first attempt so will need to be called twice.
+        when(perceptionLibrary.getSession()).thenReturn(null).thenReturn(session);
+
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionIdentityPose,
+                                ANCHOR_DIMENSIONS.width,
+                                ANCHOR_DIMENSIONS.height,
+                                PLANE_TYPE.intValue,
+                                PLANE_LABEL.intValue));
+        when(plane.createAnchor(eq(perceptionIdentityPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        FakeNode anchorNode = (FakeNode) anchorEntity.getNode();
+        FakeNode rootNode = (FakeNode) activitySpace.getNode();
+        assertThat(anchorNode.getParent()).isNull();
+
+        // The anchor starts as unanchored. Advance the executor to wait for it to become anchored
+        // and
+        // verify that the parent is the root node.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(anchorNode.getParent()).isEqualTo(rootNode);
+    }
+
+    @Test
+    public void disposeAnchor_detachesAnchor() throws Exception {
+        when(anchor.detach()).thenReturn(true);
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+        verify(anchorStateListener, never()).onStateChanged(State.ERROR);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+
+        // Verify the parent of the anchor is the root node (ActivitySpace) before disposing it.
+        FakeNode anchorNode = (FakeNode) anchorEntity.getNode();
+        FakeNode rootNode = (FakeNode) activitySpace.getNode();
+        assertThat(anchorNode.getParent()).isEqualTo(rootNode);
+        assertThat(anchorNode.getAnchorId()).isEqualTo(sharedAnchorToken);
+
+        // Dispose the entity and verify that the state was updated.
+        anchorEntity.dispose();
+
+        verify(anchorStateListener).onStateChanged(State.ERROR);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+        assertThat(anchorNode.getParent()).isNull();
+        assertThat(anchorNode.getAnchorId()).isNull();
+    }
+
+    @Test
+    public void disposeAnchorUnanchered_stopsSearching() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+
+        verify(perceptionLibrary).getSession();
+        verify(anchorStateListener, never()).onStateChanged(State.ERROR);
+        assertThat(anchorEntity.getState()).isEqualTo(State.UNANCHORED);
+
+        // Dispose the anchor entity before it was anchored.
+        anchorEntity.dispose();
+
+        // verify(anchorStateListener).onStateChanged(State.ERROR);
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+
+        // Advance the executor attempt to show that it will not call into the perception library
+        // again
+        // once disposed.
+        advanceClock(AnchorEntityImpl.ANCHOR_SEARCH_DELAY);
+        verify(perceptionLibrary).getSession();
+    }
+
+    @Test
+    public void disposeAnchorTwice_callsCalbackOnce() throws Exception {
+        when(anchor.detach()).thenReturn(true);
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        anchorEntity.setOnStateChangedListener(anchorStateListener);
+        verify(anchorStateListener, never()).onStateChanged(State.ERROR);
+
+        // Dispose the entity and verify that the state was updated.
+        anchorEntity.dispose();
+
+        verify(anchorStateListener).onStateChanged(State.ERROR);
+
+        // Dispose anchor again and verify onStateChanged was called only once.
+        anchorEntity.dispose();
+
+        verify(anchorStateListener).onStateChanged(State.ERROR);
+    }
+
+    @Test
+    public void createAnchorEntity_defaultPersistState_returnsPersistNotRequested()
+            throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_NOT_REQUESTED);
+    }
+
+    @Test
+    public void persistAnchor_notAnchored_returnsNull() throws Exception {
+        AnchorEntityImpl anchorEntity = createSemanticAnchorEntity();
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.persist()).isNull();
+    }
+
+    @Test
+    public void persistAnchor_returnsUuid() throws Exception {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        when(anchor.persist()).thenReturn(UUID.randomUUID());
+        anchorEntity.registerPersistStateChangeListener(persistStateChangeListener);
+        assertThat(anchorEntity.persist()).isNotNull();
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_PENDING);
+        verify(persistStateChangeListener).onPersistStateChanged(PersistState.PERSIST_PENDING);
+    }
+
+    @Test
+    public void persistAnchor_returnsNull() throws Exception {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        when(anchor.persist()).thenReturn(null);
+        anchorEntity.registerPersistStateChangeListener(persistStateChangeListener);
+        assertThat(anchorEntity.persist()).isNull();
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_NOT_REQUESTED);
+        verify(persistStateChangeListener, never()).onPersistStateChanged(any());
+    }
+
+    @Test
+    public void persistAnchor_secondPersist_returnsSameUuid_updatesPersistStateOnce()
+            throws Exception {
+        AnchorEntityImpl anchorEntity = createAndInitAnchorEntity();
+        when(anchor.persist()).thenReturn(UUID.randomUUID());
+        anchorEntity.registerPersistStateChangeListener(persistStateChangeListener);
+        UUID firstUuid = anchorEntity.persist();
+        assertThat(firstUuid).isNotNull();
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_PENDING);
+
+        UUID secondUuid = anchorEntity.persist();
+        assertThat(firstUuid).isEquivalentAccordingToCompareTo(secondUuid);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_PENDING);
+        verify(persistStateChangeListener).onPersistStateChanged(PersistState.PERSIST_PENDING);
+    }
+
+    @Test
+    public void persistAnchor_updatesPersistStateToPersisted() throws Exception {
+        AnchorEntityImpl anchorEntity = createInitAndPersistAnchorEntity();
+        when(anchor.getPersistState()).thenReturn(Anchor.PersistState.PERSISTED);
+
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+        verify(persistStateChangeListener).onPersistStateChanged(PersistState.PERSISTED);
+    }
+
+    @Test
+    public void updatePersistState_delayedPersistedState_callsCallback() throws Exception {
+        AnchorEntityImpl anchorEntity = createInitAndPersistAnchorEntity();
+
+        when(anchor.getPersistState())
+                .thenReturn(Anchor.PersistState.PERSIST_PENDING)
+                .thenReturn(Anchor.PersistState.PERSISTED);
+        verify(persistStateChangeListener, never()).onPersistStateChanged(PersistState.PERSISTED);
+
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(persistStateChangeListener, never()).onPersistStateChanged(PersistState.PERSISTED);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_PENDING);
+
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(persistStateChangeListener).onPersistStateChanged(PersistState.PERSISTED);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+    }
+
+    @Test
+    public void updatePersistState_noCallbackAfterStateBecomesPersisted() throws Exception {
+        AnchorEntityImpl anchorEntity = createInitAndPersistAnchorEntity();
+
+        when(anchor.getPersistState()).thenReturn(Anchor.PersistState.PERSISTED);
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSISTED);
+        verify(persistStateChangeListener).onPersistStateChanged(PersistState.PERSISTED);
+        Mockito.clearInvocations(anchor);
+        Mockito.clearInvocations(persistStateChangeListener);
+
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(anchor, never()).getPersistState();
+        verify(persistStateChangeListener, never()).onPersistStateChanged(any());
+    }
+
+    @Test
+    public void updatePersistState_noQueryForPersistStateAfterDispose() throws Exception {
+        AnchorEntityImpl anchorEntity = createInitAndPersistAnchorEntity();
+        when(anchor.getPersistState()).thenReturn(Anchor.PersistState.PERSIST_PENDING);
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(anchor).getPersistState();
+
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(anchor, times(2)).getPersistState();
+
+        Mockito.clearInvocations(anchor);
+        anchorEntity.dispose();
+        executor.simulateSleepExecutingAllTasks(AnchorEntityImpl.PERSIST_STATE_CHECK_DELAY);
+        verify(anchor, never()).getPersistState();
+    }
+
+    @Test
+    public void createAnchorEntityFromPlane_returnsAnchorEntity() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntityImpl anchorEntity = createAnchorEntityFromPlane();
+
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+        assertThat(((FakeNode) anchorEntity.getNode()).getAnchorId()).isEqualTo(sharedAnchorToken);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_NOT_REQUESTED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getParent())
+                .isEqualTo(activitySpace.getNode());
+    }
+
+    @Test
+    public void createAnchorEntityFromPlane_failureToAnchor_hasErrorState() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(plane.createAnchor(any(), any())).thenReturn(null);
+
+        AnchorEntityImpl anchorEntity = createAnchorEntityFromPlane();
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+        assertThat(((FakeNode) anchorEntity.getNode()).getParent()).isEqualTo(null);
+    }
+
+    @Test
+    public void createAnchorEntityFromPerceptionAnchor_nativePointerMatches() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        FakeExportableAnchor fakeAnchor =
+                new FakeExportableAnchor(
+                        NATIVE_POINTER,
+                        sharedAnchorToken,
+                        new Pose(),
+                        TrackingState.Tracking,
+                        PersistenceState.NotPersisted,
+                        null);
+        androidx.xr.arcore.Anchor perceptionAnchor = new androidx.xr.arcore.Anchor(fakeAnchor);
+
+        AnchorEntityImpl anchorEntity = createAnchorEntityFromPerceptionAnchor(perceptionAnchor);
+
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.nativePointer()).isEqualTo(NATIVE_POINTER);
+    }
+
+    @Test
+    public void createAnchorEntityFromPerceptionAnchor_returnsAnchorEntity() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+
+        FakeExportableAnchor fakeAnchor =
+                new FakeExportableAnchor(
+                        NATIVE_POINTER,
+                        sharedAnchorToken,
+                        new Pose(),
+                        TrackingState.Tracking,
+                        PersistenceState.NotPersisted,
+                        null);
+        androidx.xr.arcore.Anchor perceptionAnchor = new androidx.xr.arcore.Anchor(fakeAnchor);
+
+        AnchorEntityImpl anchorEntity = createAnchorEntityFromPerceptionAnchor(perceptionAnchor);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getName())
+                .isEqualTo(AnchorEntityImpl.ANCHOR_NODE_NAME);
+        assertThat(((FakeNode) anchorEntity.getNode()).getAnchorId()).isEqualTo(sharedAnchorToken);
+        assertThat(anchorEntity.getPersistState()).isEqualTo(PersistState.PERSIST_NOT_REQUESTED);
+        assertThat(((FakeNode) anchorEntity.getNode()).getParent())
+                .isEqualTo(activitySpace.getNode());
+    }
+
+    @Test
+    public void createAnchorEntityFromPerceptionAnchor_failureToAnchor_hasErrorState()
+            throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+
+        FakeExportableAnchor fakeAnchor =
+                new FakeExportableAnchor(
+                        NATIVE_POINTER,
+                        null,
+                        new Pose(),
+                        TrackingState.Tracking,
+                        PersistenceState.NotPersisted,
+                        null);
+        androidx.xr.arcore.Anchor perceptionAnchor = new androidx.xr.arcore.Anchor(fakeAnchor);
+
+        AnchorEntityImpl anchorEntity = createAnchorEntityFromPerceptionAnchor(perceptionAnchor);
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ERROR);
+        assertThat(((FakeNode) anchorEntity.getNode()).getParent()).isEqualTo(null);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImplTest.java
new file mode 100644
index 0000000..a782e82
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/AudioTrackExtensionsWrapperImplTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.media.AudioTrack;
+
+import androidx.xr.extensions.media.PointSourceAttributes;
+import androidx.xr.extensions.media.SoundFieldAttributes;
+import androidx.xr.extensions.media.SpatializerExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.AudioTrackExtensionsWrapper;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatializerConstants;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeAudioTrackExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialAudioExtensions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioTrackExtensionsWrapperImplTest {
+
+    FakeXrExtensions fakeXrExtensions;
+    FakeSpatialAudioExtensions fakeSpatialAudioExtensions;
+    FakeAudioTrackExtensions fakeAudioTrackExtensions;
+
+    private EntityManager entityManager;
+
+    @Mock private AudioTrack.Builder builder;
+
+    @Before
+    public void setUp() {
+        fakeXrExtensions = new FakeXrExtensions();
+        fakeSpatialAudioExtensions = fakeXrExtensions.fakeSpatialAudioExtensions;
+        fakeAudioTrackExtensions = new FakeAudioTrackExtensions();
+
+        fakeSpatialAudioExtensions.setFakeAudioTrackExtensions(fakeAudioTrackExtensions);
+
+        entityManager = new EntityManager();
+    }
+
+    @Test
+    public void setPointSourceAttr_callsExtensionsSetPointSourceAttr() {
+        Node fakeNode = new FakeXrExtensions().createNode();
+        AndroidXrEntity entity = mock(AndroidXrEntity.class);
+        when(entity.getNode()).thenReturn(fakeNode);
+
+        JxrPlatformAdapter.PointSourceAttributes expectedRtAttr =
+                new JxrPlatformAdapter.PointSourceAttributes(entity);
+
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+        AudioTrack.Builder actual = wrapper.setPointSourceAttributes(builder, expectedRtAttr);
+
+        assertThat(actual).isEqualTo(builder);
+        assertThat(fakeAudioTrackExtensions.getPointSourceAttributes().getNode())
+                .isEqualTo(fakeNode);
+    }
+
+    @Test
+    public void setSoundFieldAttr_callsExtensionsSetSoundFieldAttr() {
+        int expectedAmbisonicOrder = SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER;
+        JxrPlatformAdapter.SoundFieldAttributes expectedRtAttr =
+                new JxrPlatformAdapter.SoundFieldAttributes(
+                        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER);
+
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        AudioTrack.Builder actual = wrapper.setSoundFieldAttributes(builder, expectedRtAttr);
+
+        assertThat(actual).isEqualTo(builder);
+        assertThat(fakeAudioTrackExtensions.getSoundFieldAttributes().getAmbisonicsOrder())
+                .isEqualTo(expectedAmbisonicOrder);
+    }
+
+    @Test
+    public void getPointSourceAttributes_callsExtensionsGetPointSourceAttributes() {
+        AudioTrack track = mock(AudioTrack.class);
+
+        Node fakeNode = new FakeXrExtensions().createNode();
+        AndroidXrEntity entity = mock(AndroidXrEntity.class);
+        when(entity.getNode()).thenReturn(fakeNode);
+        entityManager.setEntityForNode(fakeNode, entity);
+
+        fakeAudioTrackExtensions.setPointSourceAttributes(
+                new PointSourceAttributes.Builder().setNode(fakeNode).build());
+
+        JxrPlatformAdapter.PointSourceAttributes expectedRtAttr =
+                new JxrPlatformAdapter.PointSourceAttributes(entity);
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        JxrPlatformAdapter.PointSourceAttributes actual = wrapper.getPointSourceAttributes(track);
+
+        assertThat(actual.getEntity()).isEqualTo(expectedRtAttr.getEntity());
+    }
+
+    @Test
+    public void getPointSourceAttributes_returnsNullIfNotInExtensions() {
+        AudioTrack track = mock(AudioTrack.class);
+
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        JxrPlatformAdapter.PointSourceAttributes actual = wrapper.getPointSourceAttributes(track);
+
+        assertThat(actual).isNull();
+    }
+
+    @Test
+    public void getSoundFieldAttributes_callsExtensionsGetSoundFieldAttributes() {
+        AudioTrack track = mock(AudioTrack.class);
+
+        fakeAudioTrackExtensions.setSoundFieldAttributes(
+                new SoundFieldAttributes.Builder()
+                        .setAmbisonicsOrder(SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER)
+                        .build());
+
+        JxrPlatformAdapter.SoundFieldAttributes expectedRtAttr =
+                new JxrPlatformAdapter.SoundFieldAttributes(
+                        SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER);
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        JxrPlatformAdapter.SoundFieldAttributes actual = wrapper.getSoundFieldAttributes(track);
+
+        assertThat(actual.getAmbisonicsOrder()).isEqualTo(expectedRtAttr.getAmbisonicsOrder());
+    }
+
+    @Test
+    public void getSoundFieldAttributes_returnsNullIfNotInExtensions() {
+        AudioTrack track = mock(AudioTrack.class);
+
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        JxrPlatformAdapter.SoundFieldAttributes actual = wrapper.getSoundFieldAttributes(track);
+
+        assertThat(actual).isNull();
+    }
+
+    @Test
+    public void getSourceType_returnsFromExtensions() {
+        AudioTrack track = mock(AudioTrack.class);
+
+        int expected = SpatializerConstants.SOURCE_TYPE_SOUND_FIELD;
+
+        fakeAudioTrackExtensions.setSourceType(expected);
+        AudioTrackExtensionsWrapper wrapper =
+                new AudioTrackExtensionsWrapperImpl(fakeAudioTrackExtensions, entityManager);
+
+        int actualSourceType = wrapper.getSpatialSourceType(track);
+        assertThat(actualSourceType).isEqualTo(expected);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImplTest.java
new file mode 100644
index 0000000..b837469
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/CameraViewActivityPoseImplTest.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose.CameraType;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose.Fov;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.impl.perception.ViewProjection;
+import androidx.xr.scenecore.impl.perception.ViewProjections;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class CameraViewActivityPoseImplTest {
+
+    private final AndroidXrEntity activitySpaceRoot = Mockito.mock(AndroidXrEntity.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final PerceptionLibrary perceptionLibrary = Mockito.mock(PerceptionLibrary.class);
+    private final Session session = Mockito.mock(Session.class);
+    private final FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+    private final ActivitySpaceImpl activitySpace =
+            new ActivitySpaceImpl(
+                    fakeExtensions.createNode(),
+                    fakeExtensions,
+                    new EntityManager(),
+                    () -> fakeExtensions.fakeSpatialState,
+                    executor);
+
+    /** Creates a CameraViewActivityPoseImpl. */
+    private CameraViewActivityPoseImpl createCameraViewActivityPose(@CameraType int cameraType) {
+        return new CameraViewActivityPoseImpl(
+                cameraType, activitySpace, activitySpaceRoot, perceptionLibrary);
+    }
+
+    @Test
+    public void getCameraType_returnsCameraType() {
+        CameraViewActivityPoseImpl cameraActivityPoseLeft =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+        assertThat(cameraActivityPoseLeft.getCameraType())
+                .isEqualTo(CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+
+        CameraViewActivityPoseImpl cameraActivityPoseRight =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+        assertThat(cameraActivityPoseRight.getCameraType())
+                .isEqualTo(CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+    }
+
+    @Test
+    public void getFov_returnsCorrectFov() {
+        Fov fovLeft = new Fov(1, 2, 3, 4);
+        Fov fovRight = new Fov(5, 6, 7, 8);
+
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        ViewProjection viewProjectionLeft =
+                new ViewProjection(
+                        RuntimeUtils.poseToPerceptionPose(new Pose()),
+                        RuntimeUtils.perceptionFovFromFov(fovLeft));
+        ViewProjection viewProjectionRight =
+                new ViewProjection(
+                        RuntimeUtils.poseToPerceptionPose(new Pose()),
+                        RuntimeUtils.perceptionFovFromFov(fovRight));
+        when(session.getStereoViews())
+                .thenReturn(new ViewProjections(viewProjectionLeft, viewProjectionRight));
+
+        CameraViewActivityPoseImpl cameraActivityPoseLeft =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+        assertThat(cameraActivityPoseLeft.getFov().angleLeft).isEqualTo(fovLeft.angleLeft);
+        assertThat(cameraActivityPoseLeft.getFov().angleRight).isEqualTo(fovLeft.angleRight);
+        assertThat(cameraActivityPoseLeft.getFov().angleUp).isEqualTo(fovLeft.angleUp);
+        assertThat(cameraActivityPoseLeft.getFov().angleDown).isEqualTo(fovLeft.angleDown);
+
+        CameraViewActivityPoseImpl cameraActivityPoseRight =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+        assertThat(cameraActivityPoseRight.getFov().angleLeft).isEqualTo(fovRight.angleLeft);
+        assertThat(cameraActivityPoseRight.getFov().angleRight).isEqualTo(fovRight.angleRight);
+        assertThat(cameraActivityPoseRight.getFov().angleUp).isEqualTo(fovRight.angleUp);
+        assertThat(cameraActivityPoseRight.getFov().angleDown).isEqualTo(fovRight.angleDown);
+    }
+
+    @Test
+    public void transformPoseTo_returnsCorrectPose() {
+        // Set the activity space to the root of the underlying OpenXR reference space.
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        Pose poseLeft = new Pose(new Vector3(1, 2, 3), new Quaternion(1, 0, 0, 0));
+        Pose poseRight = new Pose(new Vector3(4, 5, 6), new Quaternion(0, 1, 0, 0));
+        Fov fov = new Fov(0, 0, 0, 0);
+
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        ViewProjection viewProjectionLeft =
+                new ViewProjection(
+                        RuntimeUtils.poseToPerceptionPose(poseLeft),
+                        RuntimeUtils.perceptionFovFromFov(fov));
+        ViewProjection viewProjectionRight =
+                new ViewProjection(
+                        RuntimeUtils.poseToPerceptionPose(poseRight),
+                        RuntimeUtils.perceptionFovFromFov(fov));
+        when(session.getStereoViews())
+                .thenReturn(new ViewProjections(viewProjectionLeft, viewProjectionRight));
+
+        CameraViewActivityPoseImpl cameraActivityPoseLeft =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+
+        assertPose(cameraActivityPoseLeft.transformPoseTo(new Pose(), activitySpace), poseLeft);
+
+        CameraViewActivityPoseImpl cameraActivityPoseRight =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+
+        assertPose(cameraActivityPoseRight.transformPoseTo(new Pose(), activitySpace), poseRight);
+    }
+
+    @Test
+    public void getActivitySpaceScale_returnsInverseOfActivitySpaceWorldScale() throws Exception {
+        float activitySpaceScale = 5f;
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromScale(activitySpaceScale));
+        CameraViewActivityPoseImpl cameraActivityPoseLeft =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+        assertVector3(
+                cameraActivityPoseLeft.getActivitySpaceScale(),
+                new Vector3(1f, 1f, 1f).div(activitySpaceScale));
+
+        CameraViewActivityPoseImpl cameraActivityPoseRight =
+                createCameraViewActivityPose(CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+        assertVector3(
+                cameraActivityPoseRight.getActivitySpaceScale(),
+                new Vector3(1f, 1f, 1f).div(activitySpaceScale));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/EntityManagerTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/EntityManagerTest.java
new file mode 100644
index 0000000..4674fe0
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/EntityManagerTest.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Binder;
+import android.os.SystemClock;
+import android.view.Display;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+
+import androidx.xr.extensions.node.Node;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivityPanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.Objects;
+
+@RunWith(RobolectricTestRunner.class)
+public class EntityManagerTest {
+
+    private static final int VGA_WIDTH = 640;
+    private static final int VGA_HEIGHT = 480;
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final Session session = mock(Session.class);
+    private final AndroidXrEntity activitySpaceRoot = mock(AndroidXrEntity.class);
+    private final FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+    private final Node panelEntityNode = fakeExtensions.createNode();
+    private final Node anchorEntityNode = fakeExtensions.createNode();
+    private final EntityManager entityManager = new EntityManager();
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    private final ImpSplitEngineRenderer splitEngineRenderer =
+            Mockito.mock(ImpSplitEngineRenderer.class);
+    private Node contentLessEntityNode;
+    private Node gltfEntityNode;
+    private Activity activity;
+    private JxrPlatformAdapterAxr runtime;
+    private ActivitySpaceImpl activitySpace;
+
+    @Before
+    public void setUp() {
+        try (ActivityController<Activity> activityController =
+                Robolectric.buildActivity(Activity.class)) {
+            activity = activityController.create().start().get();
+        }
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(session));
+        runtime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        entityManager,
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+        Node taskNode = fakeExtensions.createNode();
+        this.activitySpace =
+                new ActivitySpaceImpl(
+                        taskNode,
+                        fakeExtensions,
+                        entityManager,
+                        () -> fakeExtensions.fakeSpatialState,
+                        executor);
+        long currentTimeMillis = 1000000000L;
+        SystemClock.setCurrentTimeMillis(currentTimeMillis);
+
+        // By default, set the activity space to the root of the underlying OpenXR reference space.
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+    }
+
+    @Test
+    public void creatingEntity_addsEntityToEntityManager() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        // Entity manager also contains the main panel entity and activity space, which are created
+        // when
+        // the runtime is created.
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(5);
+        assertThat(entityManager.getAllEntities())
+                .containsAtLeast(
+                        gltfEntity,
+                        panelEntity,
+                        contentlessEntity,
+                        anchorEntity,
+                        activityPanelEntity);
+    }
+
+    @Test
+    public void getEntityForNode_returnsEntity() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        Node testNode = fakeExtensions.createNode();
+
+        assertThat(entityManager.getEntityForNode(gltfEntityNode)).isEqualTo(gltfEntity);
+        assertThat(entityManager.getEntityForNode(panelEntityNode)).isEqualTo(panelEntity);
+        assertThat(entityManager.getEntityForNode(contentLessEntityNode))
+                .isEqualTo(contentlessEntity);
+        assertThat(entityManager.getEntityForNode(anchorEntityNode)).isEqualTo(anchorEntity);
+        assertThat(entityManager.getEntityForNode(testNode)).isNull();
+    }
+
+    @Test
+    public void getEntityByType_returnsEntityOfType() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        assertThat(entityManager.getEntitiesOfType(GltfEntity.class)).containsExactly(gltfEntity);
+        // MainPanel is also a PanelEntity.
+        assertThat(entityManager.getEntitiesOfType(PanelEntity.class)).contains(panelEntity);
+        // Base class of all entities.
+        assertThat(entityManager.getEntitiesOfType(Entity.class)).contains(contentlessEntity);
+        assertThat(entityManager.getEntitiesOfType(AnchorEntity.class))
+                .containsExactly(anchorEntity);
+        assertThat(entityManager.getEntitiesOfType(ActivityPanelEntity.class))
+                .containsExactly(activityPanelEntity);
+    }
+
+    @Test
+    public void removeEntity_removesFromEntityManager() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(5);
+        assertThat(entityManager.getAllEntities())
+                .containsAtLeast(
+                        gltfEntity,
+                        panelEntity,
+                        contentlessEntity,
+                        anchorEntity,
+                        activityPanelEntity);
+
+        entityManager.removeEntityForNode(contentLessEntityNode);
+
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(4);
+        assertThat(entityManager.getAllEntities()).doesNotContain(contentlessEntity);
+    }
+
+    @Test
+    public void disposeEntity_removesFromEntityManager() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(5);
+        assertThat(entityManager.getAllEntities())
+                .containsAtLeast(
+                        gltfEntity,
+                        panelEntity,
+                        contentlessEntity,
+                        anchorEntity,
+                        activityPanelEntity);
+
+        contentlessEntity.dispose();
+
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(4);
+        assertThat(entityManager.getAllEntities()).doesNotContain(contentlessEntity);
+    }
+
+    @Test
+    public void clearEntityManager_removesAllEntityFromEntityManager() throws Exception {
+        GltfEntity gltfEntity = createGltfEntity();
+        PanelEntity panelEntity = createPanelEntity();
+        Entity contentlessEntity = createContentlessEntity();
+        AnchorEntity anchorEntity = createAnchorEntity();
+        ActivityPanelEntity activityPanelEntity = createActivityPanelEntity();
+
+        assertThat(entityManager.getAllEntities().size()).isAtLeast(5);
+        assertThat(entityManager.getAllEntities())
+                .containsAtLeast(
+                        gltfEntity,
+                        panelEntity,
+                        contentlessEntity,
+                        anchorEntity,
+                        activityPanelEntity);
+
+        entityManager.clear();
+
+        assertThat(entityManager.getAllEntities()).isEmpty();
+    }
+
+    private GltfEntity createGltfEntity() throws Exception {
+        ListenableFuture<GltfModelResource> modelFuture =
+                runtime.loadGltfByAssetName("FakeAsset.glb");
+        assertThat(modelFuture).isNotNull();
+        GltfModelResource model = modelFuture.get();
+        GltfEntityImpl gltfEntity =
+                new GltfEntityImpl(
+                        (GltfModelResourceImpl) model,
+                        activitySpaceRoot,
+                        fakeExtensions,
+                        entityManager,
+                        executor);
+        gltfEntityNode = gltfEntity.getNode();
+        entityManager.setEntityForNode(gltfEntityNode, gltfEntity);
+        return gltfEntity;
+    }
+
+    private PanelEntity createPanelEntity() {
+        Display display = activity.getSystemService(DisplayManager.class).getDisplays()[0];
+        Context displayContext = activity.createDisplayContext(display);
+        View view = new View(displayContext);
+        view.setLayoutParams(new LayoutParams(VGA_WIDTH, VGA_HEIGHT));
+        SurfaceControlViewHost surfaceControlViewHost =
+                new SurfaceControlViewHost(
+                        displayContext,
+                        Objects.requireNonNull(displayContext.getDisplay()),
+                        new Binder());
+        surfaceControlViewHost.setView(view, VGA_WIDTH, VGA_HEIGHT);
+        PanelEntityImpl panelEntity =
+                new PanelEntityImpl(
+                        panelEntityNode,
+                        fakeExtensions,
+                        entityManager,
+                        surfaceControlViewHost,
+                        new PixelDimensions(VGA_WIDTH, VGA_HEIGHT),
+                        executor);
+        entityManager.setEntityForNode(panelEntityNode, panelEntity);
+        return panelEntity;
+    }
+
+    private Entity createContentlessEntity() {
+        Entity contentlessEntity =
+                runtime.createEntity(new Pose(), "testContentLess", runtime.getActivitySpace());
+        contentLessEntityNode = ((AndroidXrEntity) contentlessEntity).getNode();
+        entityManager.setEntityForNode(contentLessEntityNode, contentlessEntity);
+        return contentlessEntity;
+    }
+
+    private AnchorEntity createAnchorEntity() {
+        AnchorEntityImpl anchorEntity =
+                AnchorEntityImpl.createSemanticAnchor(
+                        anchorEntityNode,
+                        new Dimensions(1f, 1f, 1f),
+                        PlaneType.VERTICAL,
+                        PlaneSemantic.WALL,
+                        null,
+                        activitySpace,
+                        activitySpaceRoot,
+                        fakeExtensions,
+                        entityManager,
+                        executor,
+                        perceptionLibrary);
+        entityManager.setEntityForNode(anchorEntityNode, anchorEntity);
+        return anchorEntity;
+    }
+
+    private ActivityPanelEntity createActivityPanelEntity() {
+        return runtime.createActivityPanelEntity(
+                new Pose(),
+                new PixelDimensions(VGA_WIDTH, VGA_HEIGHT),
+                "test",
+                activity,
+                activitySpace);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/InteractableComponentImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/InteractableComponentImplTest.java
new file mode 100644
index 0000000..2995e1a
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/InteractableComponentImplTest.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.InteractableComponent;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeInputEvent;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.concurrent.Executor;
+
+@RunWith(RobolectricTestRunner.class)
+public class InteractableComponentImplTest {
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity activity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private JxrPlatformAdapterAxr fakeRuntime;
+    SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    ImpSplitEngineRenderer splitEngineRenderer = Mockito.mock(ImpSplitEngineRenderer.class);
+
+    private Entity createTestEntity() {
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(mock(Session.class)));
+        fakeRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+        return fakeRuntime.createEntity(new Pose(), "test", fakeRuntime.getActivitySpace());
+    }
+
+    @Test
+    public void addInteractableComponent_addsListenerToNode() {
+        Entity entity = createTestEntity();
+        Executor executor = directExecutor();
+        InputEventListener inputEventListener = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                new InteractableComponentImpl(executor, inputEventListener);
+        assertThat(entity.addComponent(interactableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getListener()).isNotNull();
+        assertThat(node.getExecutor()).isEqualTo(fakeExecutor);
+
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        assertThat(((AndroidXrEntity) entity).inputEventListenerMap).isNotEmpty();
+        verify(inputEventListener).onInputEvent(any());
+    }
+
+    @Test
+    public void removeInteractableComponent_removesListenerFromNode() {
+        Entity entity = createTestEntity();
+        Executor executor = directExecutor();
+        InputEventListener inputEventListener = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                new InteractableComponentImpl(executor, inputEventListener);
+        assertThat(entity.addComponent(interactableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getListener()).isNotNull();
+        assertThat(node.getExecutor()).isEqualTo(fakeExecutor);
+
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        assertThat(((AndroidXrEntity) entity).inputEventListenerMap).isNotEmpty();
+        verify(inputEventListener).onInputEvent(any());
+
+        entity.removeComponent(interactableComponent);
+        assertThat(node.getListener()).isNull();
+        assertThat(node.getExecutor()).isNull();
+    }
+
+    @Test
+    public void interactableComponent_canAttachOnlyOnce() {
+        Entity entity = createTestEntity();
+        Entity entity2 = createTestEntity();
+        Executor executor = directExecutor();
+        InputEventListener inputEventListener = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                new InteractableComponentImpl(executor, inputEventListener);
+        assertThat(entity.addComponent(interactableComponent)).isTrue();
+        assertThat(entity2.addComponent(interactableComponent)).isFalse();
+    }
+
+    @Test
+    public void interactableComponent_canAttachAgainAfterDetach() {
+        Entity entity = createTestEntity();
+        Executor executor = directExecutor();
+        InputEventListener inputEventListener = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                new InteractableComponentImpl(executor, inputEventListener);
+        assertThat(entity.addComponent(interactableComponent)).isTrue();
+        entity.removeComponent(interactableComponent);
+        assertThat(entity.addComponent(interactableComponent)).isTrue();
+    }
+
+    @Test
+    public void interactableComponent_enablesColliderForGltfEntity() {
+        GltfEntityImplSplitEngine gltfEntity = mock(GltfEntityImplSplitEngine.class);
+        Executor executor = directExecutor();
+        InputEventListener inputEventListener = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                new InteractableComponentImpl(executor, inputEventListener);
+        assertThat(interactableComponent.onAttach(gltfEntity)).isTrue();
+        verify(gltfEntity).setColliderEnabled(true);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxrTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxrTest.java
new file mode 100644
index 0000000..3d30602
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/JxrPlatformAdapterAxrTest.java
@@ -0,0 +1,2269 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertRotation;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.view.Display;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.ActivitySpace;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity.State;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose;
+import androidx.xr.scenecore.JxrPlatformAdapter.Component;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.ExrImageResource;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.GltfModelResource;
+import androidx.xr.scenecore.JxrPlatformAdapter.HeadActivityPose;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.InteractableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.LoggingEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.MovableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment;
+import androidx.xr.scenecore.JxrPlatformAdapter.StereoSurfaceEntity;
+import androidx.xr.scenecore.impl.perception.Anchor;
+import androidx.xr.scenecore.impl.perception.Fov;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.impl.perception.ViewProjection;
+import androidx.xr.scenecore.impl.perception.ViewProjections;
+import androidx.xr.scenecore.impl.perception.exceptions.FailedToInitializeException;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeEnvironmentToken;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeEnvironmentVisibilityState;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeGltfModelToken;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeInputEvent;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeInputEvent.FakeHitInfo;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakePassthroughVisibilityState;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialState;
+import androidx.xr.scenecore.testing.FakeXrExtensions.SpaceMode;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.androidxr.splitengine.SubspaceNode;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+@RunWith(RobolectricTestRunner.class)
+public final class JxrPlatformAdapterAxrTest {
+    private static final int OPEN_XR_REFERENCE_SPACE_TYPE = 1;
+
+    private static final int SUBSPACE_ID = 5;
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final FakeNode subspaceNode = (FakeNode) fakeExtensions.createNode();
+    private final SubspaceNode expectedSubspace = new SubspaceNode(SUBSPACE_ID, subspaceNode);
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final Session session = mock(Session.class);
+    private final Plane plane = mock(Plane.class);
+    private final Anchor anchor = mock(Anchor.class);
+    private final IBinder sharedAnchorToken = mock(IBinder.class);
+    SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    ImpSplitEngineRenderer splitEngineRenderer = Mockito.mock(ImpSplitEngineRenderer.class);
+    private ActivityController<Activity> activityController;
+    private Activity activity;
+    private JxrPlatformAdapter realityCoreRuntime;
+
+    @Before
+    public void setUp() {
+        activityController = Robolectric.buildActivity(Activity.class);
+        activity = activityController.create().start().get();
+        fakeExtensions.setOpenXrWorldSpaceType(OPEN_XR_REFERENCE_SPACE_TYPE);
+        when(perceptionLibrary.initSession(activity, OPEN_XR_REFERENCE_SPACE_TYPE, fakeExecutor))
+                .thenReturn(immediateFuture(session));
+
+        realityCoreRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+    }
+
+    GltfEntity createGltfEntity() throws Exception {
+        return createGltfEntity(new Pose());
+    }
+
+    GltfEntity createGltfEntity(Pose pose) throws Exception {
+        ListenableFuture<GltfModelResource> modelFuture =
+                realityCoreRuntime.loadGltfByAssetName("FakeAsset.glb");
+        assertThat(modelFuture).isNotNull();
+        GltfModelResource model = modelFuture.get();
+        return realityCoreRuntime.createGltfEntity(
+                pose, model, realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    GltfEntity createGltfEntitySplitEngine() throws Exception {
+        return createGltfEntitySplitEngine(new Pose());
+    }
+
+    GltfEntity createGltfEntitySplitEngine(Pose pose) throws Exception {
+        FakeNode rootNode = (FakeNode) fakeExtensions.createNode();
+        FakeNode taskWindowLeashNode = (FakeNode) fakeExtensions.createNode();
+
+        when(splitEngineSubspaceManager.createSubspace(anyString(), anyInt()))
+                .thenReturn(expectedSubspace);
+
+        JxrPlatformAdapterAxr realityCoreRuntimeWithSplitEngine =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        rootNode,
+                        taskWindowLeashNode,
+                        /* useSplitEngine= */ true);
+
+        realityCoreRuntimeWithSplitEngine.setSplitEngineSubspaceManager(splitEngineSubspaceManager);
+
+        ListenableFuture<GltfModelResource> modelFuture =
+                realityCoreRuntimeWithSplitEngine.loadGltfByAssetNameSplitEngine("FakeAsset.glb");
+        assertThat(modelFuture).isNotNull();
+        // This resolves the transformation of the Future from a SplitEngine token to the JXR
+        // GltfModelResource.  This is a hidden detail from the API surface's perspective.
+        fakeExecutor.runAll();
+        GltfModelResource model = modelFuture.get();
+        return realityCoreRuntimeWithSplitEngine.createGltfEntity(
+                pose, model, realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    private PanelEntity createPanelEntity() {
+        return createPanelEntity(new Pose());
+    }
+
+    /**
+     * Creates a generic panel entity instance for testing by creating a dummy view to insert into
+     * the panel, and setting the activity space as parent.
+     */
+    private PanelEntity createPanelEntity(Pose pose) {
+        Display display = activity.getSystemService(DisplayManager.class).getDisplays()[0];
+        Context displayContext = activity.createDisplayContext(display);
+        View view = new View(displayContext);
+        view.setLayoutParams(new LayoutParams(640, 480));
+        return realityCoreRuntime.createPanelEntity(
+                pose,
+                view,
+                new PixelDimensions(640, 480),
+                new Dimensions(0.5f, 0.5f, 0.5f),
+                "testPanel",
+                displayContext,
+                realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    private Entity createContentlessEntity() {
+        return createContentlessEntity(new Pose());
+    }
+
+    private Entity createContentlessEntity(Pose pose) {
+        return realityCoreRuntime.createEntity(
+                pose, "test", realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    @Test
+    public void initRuntimePerceptionFailure() {
+        ListenableFuture<Session> sessionFuture =
+                immediateFailedFuture(
+                        new FailedToInitializeException("Failed to initialize a session."));
+        when(perceptionLibrary.initSession(activity, OPEN_XR_REFERENCE_SPACE_TYPE, fakeExecutor))
+                .thenReturn(sessionFuture);
+
+        realityCoreRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        // The perception library failed to initialize a session, but the runtime should still be
+        // created.
+        assertThat(realityCoreRuntime).isNotNull();
+    }
+
+    @Test
+    public void requestHomeSpaceMode_callsExtensions() {
+        realityCoreRuntime.requestHomeSpaceMode();
+        assertThat(fakeExtensions.getSpaceMode()).isEqualTo(SpaceMode.HOME_SPACE);
+    }
+
+    @Test
+    public void requestFullSpaceMode_callsExtensions() {
+        realityCoreRuntime.requestFullSpaceMode();
+        assertThat(fakeExtensions.getSpaceMode()).isEqualTo(SpaceMode.FULL_SPACE);
+    }
+
+    @Test
+    public void createLoggingEntity_returnsEntity() {
+        Pose pose = new Pose();
+        LoggingEntity loggingeEntity = realityCoreRuntime.createLoggingEntity(pose);
+        Pose updatedPose =
+                new Pose(
+                        new Vector3(1f, pose.getTranslation().getY(), pose.getTranslation().getZ()),
+                        pose.getRotation());
+        loggingeEntity.setPose(updatedPose);
+    }
+
+    @Test
+    public void loggingEntitySetParent() {
+        Pose pose = new Pose();
+        LoggingEntity childEntity = realityCoreRuntime.createLoggingEntity(pose);
+        LoggingEntity parentEntity = realityCoreRuntime.createLoggingEntity(pose);
+
+        childEntity.setParent(parentEntity);
+        parentEntity.addChild(childEntity);
+
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getParent()).isEqualTo(null);
+        assertThat(childEntity.getChildren()).isEmpty();
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity);
+    }
+
+    @Test
+    public void loggingEntityUpdateParent() {
+        Pose pose = new Pose();
+        LoggingEntity childEntity = realityCoreRuntime.createLoggingEntity(pose);
+        LoggingEntity parentEntity1 = realityCoreRuntime.createLoggingEntity(pose);
+        LoggingEntity parentEntity2 = realityCoreRuntime.createLoggingEntity(pose);
+
+        childEntity.setParent(parentEntity1);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity1);
+        assertThat(parentEntity1.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity2.getChildren()).isEmpty();
+
+        childEntity.setParent(parentEntity2);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity2);
+        assertThat(parentEntity2.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity1.getChildren()).isEmpty();
+    }
+
+    @Test
+    public void onSpatialStateChanged_setsSpatialCapabilities() {
+        realityCoreRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        FakeSpatialState spatialState = new FakeSpatialState();
+        spatialState.setSpatialCapabilities(
+                new androidx.xr.extensions.space.SpatialCapabilities() {
+                    @Override
+                    public boolean get(int capability) {
+                        return capability
+                                == androidx.xr.extensions.space.SpatialCapabilities
+                                        .SPATIAL_UI_CAPABLE;
+                    }
+                });
+        ((JxrPlatformAdapterAxr) realityCoreRuntime).onSpatialStateChanged(spatialState);
+
+        SpatialCapabilities caps = realityCoreRuntime.getSpatialCapabilities();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+    }
+
+    @Test
+    public void onSpatialStateChanged_setsEnvironmentVisibility() {
+        SpatialEnvironment environment = realityCoreRuntime.getSpatialEnvironment();
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+
+        FakeSpatialState state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.APP_VISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isTrue();
+
+        state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.INVISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+
+        state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.HOME_VISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+    }
+
+    @Test
+    public void onSpatialStateChanged_callsEnvironmentListenerOnlyForChanges() {
+        SpatialEnvironment environment = realityCoreRuntime.getSpatialEnvironment();
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Boolean> listener = (Consumer<Boolean>) mock(Consumer.class);
+
+        environment.addOnSpatialEnvironmentChangedListener(listener);
+
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+
+        // The first spatial state should always fire the listener
+        FakeSpatialState state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.APP_VISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        verify(listener).accept(true);
+
+        // The second spatial state should also fire the listener since it's a different state
+        state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.INVISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+        verify(listener).accept(false);
+
+        // The third spatial state should not fire the listener since it is the same as the last
+        // state.
+        state = new FakeSpatialState();
+        state.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.INVISIBLE));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+        verify(listener, times(2))
+                .accept(any()); // Verify the listener was not called a third time.
+    }
+
+    @Test
+    public void onSpatialStateChanged_setsPassthroughOpacity() {
+        SpatialEnvironment environment = realityCoreRuntime.getSpatialEnvironment();
+        assertThat(environment.getCurrentPassthroughOpacity()).isZero();
+
+        FakeSpatialState state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 0.4f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.4f);
+
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.HOME, 0.5f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.SYSTEM, 0.9f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.9f);
+
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.DISABLED, 0.0f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isZero();
+    }
+
+    @Test
+    public void onSpatialStateChanged_callsPassthroughListenerOnlyForChanges() {
+        SpatialEnvironment environment = realityCoreRuntime.getSpatialEnvironment();
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Float> listener = (Consumer<Float>) mock(Consumer.class);
+
+        environment.addOnPassthroughOpacityChangedListener(listener);
+
+        assertThat(environment.getCurrentPassthroughOpacity()).isZero();
+
+        // The first spatial state should always fire the listener
+        FakeSpatialState state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 1.0f));
+        fakeExtensions.sendSpatialState(state);
+        verify(listener).accept(1.0f);
+
+        // The second spatial state should also fire the listener even if only the opacity changes
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 0.5f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+
+        // The third spatial state should also fire the listener even if only the visibility state
+        // changes, but getCurrentPassthroughOpacity() returns the same value as the last state.
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.HOME, 0.5f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+        verify(listener, times(2))
+                .accept(0.5f); // Verify it was called a second time with this value.
+
+        // The fourth spatial state should not fire the listener since it is the same as the last
+        // state.
+        state = new FakeSpatialState();
+        state.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.HOME, 0.5f));
+        fakeExtensions.sendSpatialState(state);
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+        verify(listener, times(3))
+                .accept(any()); // Verify the listener was not called a fourth time.
+    }
+
+    @Test
+    public void currentPassthroughOpacity_isSetDuringRuntimeCreation() {
+        fakeExtensions.fakeSpatialState.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 0.5f));
+
+        JxrPlatformAdapter newRealityCoreRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        SpatialEnvironment newEnvironment = newRealityCoreRuntime.getSpatialEnvironment();
+        assertThat(newEnvironment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+    }
+
+    @Test
+    public void onSpatialStateChanged_firesSpatialCapabilitiesChangedListener() {
+        realityCoreRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        @SuppressWarnings(value = "unchecked")
+        Consumer<SpatialCapabilities> listener1 =
+                (Consumer<SpatialCapabilities>) mock(Consumer.class);
+        @SuppressWarnings(value = "unchecked")
+        Consumer<SpatialCapabilities> listener2 =
+                (Consumer<SpatialCapabilities>) mock(Consumer.class);
+
+        realityCoreRuntime.addSpatialCapabilitiesChangedListener(directExecutor(), listener1);
+        realityCoreRuntime.addSpatialCapabilitiesChangedListener(directExecutor(), listener2);
+
+        FakeSpatialState state = new FakeSpatialState();
+        state.setSpatialCapabilities(
+                new androidx.xr.extensions.space.SpatialCapabilities() {
+                    @Override
+                    public boolean get(int capability) {
+                        return true;
+                    }
+                });
+        fakeExtensions.sendSpatialState(state);
+        verify(listener1).accept(any());
+        verify(listener2).accept(any());
+
+        state = new FakeSpatialState();
+        state.setSpatialCapabilities(
+                new androidx.xr.extensions.space.SpatialCapabilities() {
+                    @Override
+                    public boolean get(int capability) {
+                        return false;
+                    }
+                });
+        realityCoreRuntime.removeSpatialCapabilitiesChangedListener(listener1);
+        fakeExtensions.sendSpatialState(state);
+        verify(listener1).accept(any()); // Verify the removed listener was called exactly once
+        verify(listener2, times(2)).accept(any()); // Verify the active listener was called twice
+    }
+
+    @Test
+    public void getHeadPoseInOpenXrUnboundedSpace_returnsNullWhenPerceptionSessionUninitialized() {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+        assertThat(((JxrPlatformAdapterAxr) realityCoreRuntime).getHeadPoseInOpenXrUnboundedSpace())
+                .isNull();
+    }
+
+    @Test
+    public void getHeadPoseInOpenXrUnboundedSpace_returnsPose() {
+        when(session.getHeadPose())
+                .thenReturn(
+                        new androidx.xr.scenecore.impl.perception.Pose(1f, 1f, 1f, 0f, 0f, 0f, 1f));
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        assertPose(
+                ((JxrPlatformAdapterAxr) realityCoreRuntime).getHeadPoseInOpenXrUnboundedSpace(),
+                new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f)));
+    }
+
+    @Test
+    public void
+            getStereoViewsInOpenXrUnboundedSpace_returnsNullWhenPerceptionSessionUninitialized() {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+        assertThat(
+                        ((JxrPlatformAdapterAxr) realityCoreRuntime)
+                                .getStereoViewsInOpenXrUnboundedSpace())
+                .isNull();
+    }
+
+    @Test
+    public void getStereoViewsInOpenXrUnboundedSpace_returnsViewProjections() {
+        ViewProjection leftViewProjection =
+                new ViewProjection(
+                        new androidx.xr.scenecore.impl.perception.Pose(-1f, 1f, 1f, 0f, 0f, 0f, 1f),
+                        new Fov(-1f, -1f, -1f, -1f));
+
+        ViewProjection rightViewProjection =
+                new ViewProjection(
+                        new androidx.xr.scenecore.impl.perception.Pose(1f, 1f, 1f, 0f, 0f, 0f, 1f),
+                        new Fov(1f, 1f, 1f, 1f));
+
+        when(session.getStereoViews())
+                .thenReturn(new ViewProjections(leftViewProjection, rightViewProjection));
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        assertThat(
+                        ((JxrPlatformAdapterAxr) realityCoreRuntime)
+                                .getStereoViewsInOpenXrUnboundedSpace())
+                .isEqualTo(new ViewProjections(leftViewProjection, rightViewProjection));
+    }
+
+    @Test
+    public void loggingEntity_getActivitySpacePose_returnsIdentityPose() {
+        Pose identityPose = new Pose();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(identityPose);
+        assertPose(loggingEntity.getActivitySpacePose(), identityPose);
+    }
+
+    @Test
+    public void loggingEntity_transformPoseTo_returnsIdentityPose() {
+        Pose identityPose = new Pose();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(identityPose);
+        assertPose(loggingEntity.transformPoseTo(identityPose, loggingEntity), identityPose);
+    }
+
+    @Test
+    public void getPose_returnsSetPose() throws Exception {
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), new Quaternion(1f, 2f, 3f, 4f));
+        Pose identityPose = new Pose();
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(identityPose);
+        Entity contentlessEntity = createContentlessEntity();
+
+        assertPose(panelEntity.getPose(), identityPose);
+        assertPose(gltfEntity.getPose(), identityPose);
+        assertPose(loggingEntity.getPose(), identityPose);
+        assertPose(contentlessEntity.getPose(), identityPose);
+
+        panelEntity.setPose(pose);
+        gltfEntity.setPose(pose);
+        loggingEntity.setPose(pose);
+        contentlessEntity.setPose(pose);
+
+        assertPose(panelEntity.getPose(), pose);
+        assertPose(gltfEntity.getPose(), pose);
+        assertPose(loggingEntity.getPose(), pose);
+        assertPose(contentlessEntity.getPose(), pose);
+    }
+
+    @Test
+    public void getPose_returnsFactoryMethodPose() throws Exception {
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), new Quaternion(1f, 2f, 3f, 4f));
+        PanelEntity panelEntity = createPanelEntity(pose);
+        GltfEntity gltfEntity = createGltfEntity(pose);
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(pose);
+        Entity contentlessEntity = createContentlessEntity(pose);
+
+        assertPose(panelEntity.getPose(), pose);
+        assertPose(gltfEntity.getPose(), pose);
+        assertPose(loggingEntity.getPose(), pose);
+        assertPose(contentlessEntity.getPose(), pose);
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withParentChainTranslation_returnsOffsetPositionFromRoot()
+            throws Exception {
+        // Create a simple pose with only a small translation on all axes
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+
+        // Set the activity space as the root of this entity hierarchy..
+        AndroidXrEntity parentEntity =
+                (AndroidXrEntity)
+                        realityCoreRuntime.createEntity(
+                                pose, "parent", realityCoreRuntime.getActivitySpace());
+        AndroidXrEntity childEntity1 =
+                (AndroidXrEntity) realityCoreRuntime.createEntity(pose, "child1", parentEntity);
+        AndroidXrEntity childEntity2 =
+                (AndroidXrEntity) realityCoreRuntime.createEntity(pose, "child2", childEntity1);
+
+        assertVector3(
+                parentEntity.getPoseInActivitySpace().getTranslation(), new Vector3(1f, 2f, 3f));
+        assertVector3(
+                childEntity1.getPoseInActivitySpace().getTranslation(), new Vector3(2f, 4f, 6f));
+        assertVector3(
+                childEntity2.getPoseInActivitySpace().getTranslation(), new Vector3(3f, 6f, 9f));
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withParentChainRotation_returnsOffsetRotationFromRoot()
+            throws Exception {
+        // Create a pose with a translation and one with 90 degree rotation around the y axis.
+        Vector3 parentTranslation = new Vector3(1f, 2f, 3f);
+        Pose translatedPose = new Pose(parentTranslation, Quaternion.Identity);
+        Quaternion quaternion = Quaternion.fromAxisAngle(new Vector3(0f, 1f, 0f), 90f);
+        Pose rotatedPose = new Pose(new Vector3(0f, 0f, 0f), quaternion);
+
+        // The parent has a translation and no rotation.
+        AndroidXrEntity parentEntity =
+                (AndroidXrEntity)
+                        realityCoreRuntime.createEntity(
+                                translatedPose, "parent", realityCoreRuntime.getActivitySpace());
+
+        // Each child adds a rotation, but no translation.
+        AndroidXrEntity childEntity1 =
+                (AndroidXrEntity)
+                        realityCoreRuntime.createEntity(rotatedPose, "child1", parentEntity);
+        AndroidXrEntity childEntity2 =
+                (AndroidXrEntity)
+                        realityCoreRuntime.createEntity(rotatedPose, "child2", childEntity1);
+
+        // There should be no translation offset from the root, only changes in rotation.
+        assertPose(parentEntity.getPoseInActivitySpace(), translatedPose);
+        assertPose(childEntity1.getPoseInActivitySpace(), new Pose(parentTranslation, quaternion));
+        assertPose(
+                childEntity2.getPoseInActivitySpace(),
+                new Pose(
+                        parentTranslation,
+                        Quaternion.fromAxisAngle(new Vector3(0f, 1f, 0f), 180f)));
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withParentChainPoseOffsets_returnsOffsetPoseFromRoot()
+            throws Exception {
+        // Create a pose with a 1D translation and a 90 degree rotation around the z axis.
+        Vector3 parentTranslation = new Vector3(1f, 0f, 0f);
+        Quaternion quaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 90f);
+        Pose pose = new Pose(parentTranslation, quaternion);
+
+        // Each entity adds a translation and a rotation.
+        AndroidXrEntity parentEntity =
+                (AndroidXrEntity)
+                        realityCoreRuntime.createEntity(
+                                pose, "parent", realityCoreRuntime.getActivitySpace());
+        AndroidXrEntity childEntity1 =
+                (AndroidXrEntity) realityCoreRuntime.createEntity(pose, "child1", parentEntity);
+        AndroidXrEntity childEntity2 =
+                (AndroidXrEntity) realityCoreRuntime.createEntity(pose, "child2", childEntity1);
+
+        // Local pose of ActivitySpace's direct child must be the same as child's ActivitySpace
+        // pose.
+        assertPose(parentEntity.getPoseInActivitySpace(), parentEntity.getPose());
+
+        // Each child should be positioned one unit away at 90 degrees from its parent's position.
+        // Since our coordinate system is right-handed, a +ve rotation around the z axis is a
+        // counter-clockwise rotation of the XY plane.
+        // First child should be 1 unit in the ActivitySpace's positive y direction from its parent
+        assertPose(
+                childEntity1.getPoseInActivitySpace(),
+                new Pose(
+                        new Vector3(1f, 1f, 0f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 180f)));
+        // Second child should be 1 unit in the ActivitySpace's negative x direction from its parent
+        assertPose(
+                childEntity2.getPoseInActivitySpace(),
+                new Pose(
+                        new Vector3(0f, 1f, 0f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 270f)));
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withActivitySpaceParent_returnsScaledPose()
+            throws Exception {
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), new Quaternion(1f, 2f, 3f, 4f));
+
+        // Set the parent as the activity space so these entities' activitySpacePose should match
+        // their
+        // local pose relative to their parent.
+        PanelEntityImpl panelEntity = (PanelEntityImpl) createPanelEntity(pose);
+        GltfEntityImpl gltfEntity = (GltfEntityImpl) createGltfEntity(pose);
+        AndroidXrEntity contentlessEntity = (AndroidXrEntity) createContentlessEntity(pose);
+        ActivitySpace activitySpace = realityCoreRuntime.getActivitySpace();
+        panelEntity.setParent(activitySpace);
+        gltfEntity.setParent(activitySpace);
+        contentlessEntity.setParent(activitySpace);
+
+        assertPose(panelEntity.getPoseInActivitySpace(), pose);
+        assertPose(gltfEntity.getPoseInActivitySpace(), pose);
+        assertPose(contentlessEntity.getPoseInActivitySpace(), pose);
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withScale_returnsPose() throws Exception {
+        Pose localPose = new Pose(new Vector3(1f, 2f, 1f), Quaternion.Identity);
+
+        // Create a hierarchy of entities each translated from their parent by (1,2,1) in parent
+        // space.
+        GltfEntityImpl child1 = (GltfEntityImpl) createGltfEntity(localPose);
+        GltfEntityImpl child2 = (GltfEntityImpl) createGltfEntity(localPose);
+        GltfEntityImpl child3 = (GltfEntityImpl) createGltfEntity(localPose);
+        ActivitySpace activitySpace = realityCoreRuntime.getActivitySpace();
+        assertVector3(activitySpace.getScale(), new Vector3(1f, 1f, 1f));
+
+        // Set a non-unit local scale to each child.
+        child1.setParent(activitySpace);
+        child1.setScale(new Vector3(2f, 2f, 2f));
+
+        child2.setParent(child1);
+        child2.setScale(new Vector3(3f, 2f, 3f));
+
+        child3.setParent(child2);
+        child3.setScale(new Vector3(1f, 1f, 2f));
+
+        // The position (in ActivitySpace) should be:
+        // child's local position * parent's scale in AS + parent's position since there's no
+        // rotation.
+
+        // Assuming c1 = child1, c2 = child2, c3 = child3, AS = activitySpace.
+        // c1.posInAS = c1.localPos * AS.scaleInAS + AS.posInAS = (1,2,1) * (1,1,1) + (0,0,0) =
+        // (1,2,1)
+        assertPose(
+                child1.getPoseInActivitySpace(),
+                new Pose(new Vector3(1f, 2f, 1f), Quaternion.Identity));
+
+        // c2.posInAS = c2.localPos * c1.scaleInAS + c1.posInAS = (1,2,1) * (2,2,2) + (1,2,1) =
+        // (3,6,3)
+        assertPose(
+                child2.getPoseInActivitySpace(),
+                new Pose(new Vector3(3f, 6f, 3f), Quaternion.Identity));
+
+        // c2.scaleInA = c2.localScale * c1.scaleInAS * AS.scale = (3,2,3) * (2,2,2) * (1,1,1) =
+        // (6,4,6)
+        // c3.posInAS = c3.localPos * c2.scaleInAS + c2.posInAS = (1,2,1) * (6,4,6) + (3,6,3) =
+        // (9,14,9)
+        assertPose(
+                child3.getPoseInActivitySpace(),
+                new Pose(new Vector3(9f, 14f, 9f), Quaternion.Identity));
+    }
+
+    @Test
+    public void getActivitySpacePose_withParentChainTranslation_returnsOffsetPositionFromRoot()
+            throws Exception {
+        // Create a simple pose with only a small translation on all axes
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+
+        // Set the ActivitySpace as the root of this entity hierarchy.
+        Entity parentEntity =
+                realityCoreRuntime.createEntity(
+                        pose, "parent", realityCoreRuntime.getActivitySpaceRootImpl());
+        Entity childEntity1 = realityCoreRuntime.createEntity(pose, "child1", parentEntity);
+        Entity childEntity2 = realityCoreRuntime.createEntity(pose, "child2", childEntity1);
+
+        // The translations should accumulate with each child, but there should be no rotation.
+        assertVector3(
+                parentEntity.getActivitySpacePose().getTranslation(), new Vector3(1f, 2f, 3f));
+        assertVector3(
+                childEntity1.getActivitySpacePose().getTranslation(), new Vector3(2f, 4f, 6f));
+        assertVector3(
+                childEntity2.getActivitySpacePose().getTranslation(), new Vector3(3f, 6f, 9f));
+        assertRotation(childEntity2.getActivitySpacePose().getRotation(), Quaternion.Identity);
+    }
+
+    @Test
+    public void getActivitySpacePose_withParentChainRotation_returnsOffsetRotationFromRoot()
+            throws Exception {
+        // Create a pose with a translation and one with 90 degree rotation around the y axis.
+        Vector3 parentTranslation = new Vector3(1f, 0f, 0f);
+        Pose translatedPose = new Pose(parentTranslation, Quaternion.Identity);
+        Quaternion quaternion = Quaternion.fromAxisAngle(new Vector3(0f, 1f, 0f), 90f);
+        Pose rotatedPose = new Pose(new Vector3(0f, 0f, 0f), quaternion);
+
+        // The parent has a translation and no rotation and each child adds a rotation.
+        Entity parentEntity =
+                realityCoreRuntime.createEntity(
+                        translatedPose, "parent", realityCoreRuntime.getActivitySpaceRootImpl());
+        Entity childEntity1 = realityCoreRuntime.createEntity(rotatedPose, "child1", parentEntity);
+        Entity childEntity2 = realityCoreRuntime.createEntity(rotatedPose, "child2", childEntity1);
+
+        // There should be no translation offset from the parent, but rotations should accumulate.
+        assertPose(parentEntity.getActivitySpacePose(), translatedPose);
+        assertPose(childEntity1.getActivitySpacePose(), new Pose(parentTranslation, quaternion));
+        assertPose(
+                childEntity2.getActivitySpacePose(),
+                new Pose(
+                        parentTranslation,
+                        Quaternion.fromAxisAngle(new Vector3(0f, 1f, 0f), 180f)));
+    }
+
+    @Test
+    public void getActivitySpacePose_withParentChainPoseOffsets_returnsOffsetPoseFromRoot()
+            throws Exception {
+        // Create a pose with a 1D translation and a 90 degree rotation around the z axis.
+        Vector3 parentTranslation = new Vector3(1f, 0f, 0f);
+        Quaternion quaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 90f);
+        Pose pose = new Pose(parentTranslation, quaternion);
+
+        // Each entity adds a translation and a rotation.
+        Entity parentEntity =
+                realityCoreRuntime.createEntity(
+                        pose, "parent", realityCoreRuntime.getActivitySpaceRootImpl());
+        Entity childEntity1 = realityCoreRuntime.createEntity(pose, "child1", parentEntity);
+        Entity childEntity2 = realityCoreRuntime.createEntity(pose, "child2", childEntity1);
+
+        // Local pose of ActivitySpace's direct child must be the same as child's ActivitySpace
+        // pose.
+        assertPose(parentEntity.getActivitySpacePose(), parentEntity.getPose());
+
+        // Each child should be positioned one unit away at 90 degrees from its parent's position.
+        // Since our coordinate system is right-handed, a +ve rotation around the z axis is a
+        // counter-clockwise rotation of the XY plane.
+        // First child should be 1 unit in the ActivitySpace's positive y direction from its parent
+        assertPose(
+                childEntity1.getActivitySpacePose(),
+                new Pose(
+                        new Vector3(1f, 1f, 0f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 180f)));
+        // Second child should be 1 unit in the ActivitySpace's negative x direction from its parent
+        assertPose(
+                childEntity2.getActivitySpacePose(),
+                new Pose(
+                        new Vector3(0f, 1f, 0f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 270f)));
+    }
+
+    @Test
+    public void getActivitySpacePose_withDefaultParent_returnsPose() throws Exception {
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), new Quaternion(1f, 2f, 3f, 4f));
+
+        // All these entities should have the ActivitySpaceRootImpl as their parent by default.
+        PanelEntity panelEntity = createPanelEntity(pose);
+        GltfEntity gltfEntity = createGltfEntity(pose);
+        Entity contentlessEntity = createContentlessEntity(pose);
+
+        assertPose(panelEntity.getActivitySpacePose(), pose);
+        assertPose(gltfEntity.getActivitySpacePose(), pose);
+        assertPose(contentlessEntity.getActivitySpacePose(), pose);
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withScale_returnsScaledPose() throws Exception {
+        Pose localPose = new Pose(new Vector3(1f, 2f, 1f), Quaternion.Identity);
+
+        // Create a hierarchy of entities each translated from their parent by (1,1,1) in parent
+        // space.
+        GltfEntityImpl child1 = (GltfEntityImpl) createGltfEntity(localPose);
+        GltfEntityImpl child2 = (GltfEntityImpl) createGltfEntity(localPose);
+        GltfEntityImpl child3 = (GltfEntityImpl) createGltfEntity(localPose);
+        assertVector3(
+                realityCoreRuntime.getActivitySpaceRootImpl().getScale(), new Vector3(1f, 1f, 1f));
+
+        // Set a non-unit local scale to each child.
+        child1.setParent(realityCoreRuntime.getActivitySpaceRootImpl());
+        child1.setScale(new Vector3(2f, 2f, 2f));
+
+        child2.setParent(child1);
+        child2.setScale(new Vector3(3f, 2f, 3f));
+
+        child3.setParent(child2);
+        child3.setScale(new Vector3(1f, 1f, 2f));
+
+        // See getPoseInActivitySpace_withScale_returnsScaledPose for more detailed comments.
+        // The position should be (parent's scale * child's position) + parent's position
+        // since there's no rotation.
+        assertPose(
+                child1.getActivitySpacePose(),
+                new Pose(new Vector3(1f, 2f, 1f), Quaternion.Identity));
+        assertPose(
+                child2.getActivitySpacePose(),
+                new Pose(new Vector3(3f, 6f, 3f), Quaternion.Identity));
+        assertPose(
+                child3.getActivitySpacePose(),
+                new Pose(new Vector3(9f, 14f, 9f), Quaternion.Identity));
+    }
+
+    @Test
+    public void transformPoseTo_sameDestAndSourceEntity_returnsUnchangedPose() throws Exception {
+        Pose pose =
+                new Pose(new Vector3(1f, 2f, 3f), new Quaternion(1f, 2f, 3f, 4f).toNormalized());
+        Pose identity = new Pose();
+
+        PanelEntity panelEntity = createPanelEntity(pose);
+        GltfEntity gltfEntity = createGltfEntity(pose);
+        Entity contentlessEntity = createContentlessEntity(pose);
+        assertPose(panelEntity.transformPoseTo(pose, panelEntity), pose);
+        assertPose(gltfEntity.transformPoseTo(pose, gltfEntity), pose);
+        assertPose(contentlessEntity.transformPoseTo(pose, contentlessEntity), pose);
+
+        assertPose(panelEntity.transformPoseTo(identity, panelEntity), identity);
+        assertPose(gltfEntity.transformPoseTo(identity, gltfEntity), identity);
+        assertPose(contentlessEntity.transformPoseTo(identity, contentlessEntity), identity);
+    }
+
+    @Test
+    public void transformPoseTo_withOnlyTranslationOffset_returnsTranslationDifference()
+            throws Exception {
+        PanelEntityImpl sourceEntity = (PanelEntityImpl) createPanelEntity();
+        GltfEntityImpl destinationEntity = (GltfEntityImpl) createGltfEntity();
+        sourceEntity.setPose(
+                new Pose(new Vector3(1f, 2f, 3f), sourceEntity.getPose().getRotation()));
+        destinationEntity.setPose(
+                new Pose(new Vector3(4f, 5f, 6f), destinationEntity.getPose().getRotation()));
+        Pose offsetFromSource = new Pose(new Vector3(7f, 8f, 9f), Quaternion.Identity);
+
+        // The expected translation is destOffset = (sourceOrigin + sourceOffset) - destOrigin
+        // since there's no rotation and the entities are in the same coordinate space.
+        // So ((1,2,3) + (7,8,9)) - (4,5,6) = (4, 5, 6)
+        Pose offsetInDestinationSpace =
+                sourceEntity.transformPoseTo(offsetFromSource, destinationEntity);
+        Pose expectedPose = new Pose(new Vector3(4f, 5f, 6f), Quaternion.Identity);
+        assertPose(offsetInDestinationSpace, expectedPose);
+    }
+
+    @Test
+    public void transformPoseTo_withOnlyRotationOffset_returnsRotationDifference()
+            throws Exception {
+        PanelEntityImpl sourceEntity = (PanelEntityImpl) createPanelEntity();
+        GltfEntityImpl destinationEntity = (GltfEntityImpl) createGltfEntity();
+
+        sourceEntity.setPose(
+                new Pose(
+                        sourceEntity.getPose().getTranslation(),
+                        Quaternion.fromEulerAngles(new Vector3(1f, 2f, 3f))));
+        destinationEntity.setPose(
+                new Pose(
+                        destinationEntity.getPose().getTranslation(),
+                        Quaternion.fromEulerAngles(new Vector3(4f, 5f, 6f))));
+
+        Pose offsetFromSource =
+                new Pose(new Vector3(), Quaternion.fromEulerAngles(new Vector3(7f, 8f, 9f)));
+
+        // The expected rotation is (source + sourceOffset) - destination since the source and
+        // destination are in the same coordinate space: ((1,2,3) + (7,8,9)) - (4,5,6) = (4, 5, 6)
+        Pose offsetInDestinationSpace =
+                sourceEntity.transformPoseTo(offsetFromSource, destinationEntity);
+        Pose expectedPose =
+                new Pose(new Vector3(), Quaternion.fromEulerAngles(new Vector3(4f, 5f, 6f)));
+        assertPose(offsetInDestinationSpace, expectedPose);
+    }
+
+    @Test
+    public void transformPoseTo_withDifferentTranslationAndRotation_returnsTransformedPose() {
+        // Assume the source and destination entities are in the same coordinate space.
+        Vector3 sourceVector = new Vector3(1f, 2f, 3f);
+        Quaternion sourceQuaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), -90f);
+        Vector3 destinationVector = new Vector3(10f, 20f, 30f);
+        Quaternion destinationQuaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 90f);
+        Pose identity = new Pose();
+
+        AndroidXrEntity sourceEntity =
+                (AndroidXrEntity) createContentlessEntity(new Pose(sourceVector, sourceQuaternion));
+        AndroidXrEntity destinationEntity =
+                (AndroidXrEntity)
+                        createContentlessEntity(new Pose(destinationVector, destinationQuaternion));
+
+        //// Transform an identity pose from the source to the destination space. ////
+        Pose sourceToDestinationPose = sourceEntity.transformPoseTo(identity, destinationEntity);
+
+        // The expected rotation is the difference between the quaternions -90 - 90 = -180.
+        Quaternion expectedQuaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), -180f);
+        assertRotation(sourceToDestinationPose.getRotation(), expectedQuaternion);
+
+        // The expected translation is the difference between the source and destination vectors
+        // rotated
+        // by the inverse of the destination quaternion.
+        Vector3 expectedVector =
+                destinationQuaternion.getInverse().times(sourceVector.minus(destinationVector));
+
+        // So difference is (1,2,3) - (10,20,30) = (-9,-18,-27) then rotate CCW by -90 degrees
+        // around
+        // the z axis (ie. swap x and y, set x positive and y negative since we're rotating from 3rd
+        // quadrant to the 2nd quadrant of XY plane) => (-18, 9, -27)
+        assertVector3(expectedVector, new Vector3(-18f, 9f, -27f));
+        assertVector3(sourceToDestinationPose.getTranslation(), expectedVector);
+
+        //// Transform an offset pose from the source to the destination. ////
+        Pose offsetPose =
+                new Pose(
+                        new Vector3(1f, 0f, 0f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 20f));
+        Pose newSourceToDestinationPose =
+                sourceEntity.transformPoseTo(offsetPose, destinationEntity);
+
+        // The expected rotation is the difference between the quaternions (20-90) - 90 = -160.
+        Quaternion newExpectedQuaternion = Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), -160f);
+        assertRotation(newSourceToDestinationPose.getRotation(), newExpectedQuaternion);
+
+        // The expected translation is expected to be the same as the previous one but with the
+        // offset
+        // vector added to it in the destination space.
+        Vector3 offsetInActivitySpace = sourceQuaternion.times(offsetPose.getTranslation());
+        Vector3 offsetInDestinationSpace =
+                destinationQuaternion.getInverse().times(offsetInActivitySpace);
+        Vector3 newExpectedVector = expectedVector.plus(offsetInDestinationSpace);
+
+        // So (1, 0, 0) rotated by -90 degrees around the z axis is (0, 1, 0) in activity space then
+        // add to the difference from source to destination vector (-9,-18,-27) to get (-9, -19,
+        // -27)
+        // and finally rotate by the inverse of the destination quaternion to get (-19, 9, -27).
+        assertVector3(newExpectedVector, new Vector3(-19f, 9f, -27f));
+        assertVector3(newSourceToDestinationPose.getTranslation(), newExpectedVector);
+    }
+
+    @Test
+    public void getAlpha_returnsSetAlpha() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        Entity contentlessEntity = createContentlessEntity();
+
+        assertThat(panelEntity.getAlpha()).isEqualTo(1.0f);
+        assertThat(gltfEntity.getAlpha()).isEqualTo(1.0f);
+        assertThat(contentlessEntity.getAlpha()).isEqualTo(1.0f);
+
+        panelEntity.setAlpha(0.5f);
+        gltfEntity.setAlpha(0.5f);
+        contentlessEntity.setAlpha(0.5f);
+
+        assertThat(panelEntity.getAlpha()).isEqualTo(0.5f);
+        assertThat(gltfEntity.getAlpha()).isEqualTo(0.5f);
+        assertThat(contentlessEntity.getAlpha()).isEqualTo(0.5f);
+        assertThat(
+                        fakeExtensions.createdNodes.stream()
+                                .map(FakeNode::getAlpha)
+                                .collect(Collectors.toList()))
+                .containsAtLeast(0.5f, 0.5f, 0.5f);
+    }
+
+    @Test
+    public void getActivitySpaceAlpha_returnsTotalAncestorAlpha() throws Exception {
+        PanelEntity grandparent = createPanelEntity();
+        GltfEntity parent = createGltfEntity();
+        Entity entity = createContentlessEntity();
+
+        assertThat(grandparent.getActivitySpaceAlpha()).isEqualTo(1.0f);
+        assertThat(parent.getActivitySpaceAlpha()).isEqualTo(1.0f);
+        assertThat(entity.getActivitySpaceAlpha()).isEqualTo(1.0f);
+
+        grandparent.setAlpha(0.5f);
+        parent.setParent(grandparent);
+        parent.setAlpha(0.5f);
+        entity.setParent(parent);
+        entity.setAlpha(0.5f);
+
+        assertThat(grandparent.getActivitySpaceAlpha()).isEqualTo(0.5f);
+        assertThat(parent.getActivitySpaceAlpha()).isEqualTo(0.25f);
+        assertThat(entity.getActivitySpaceAlpha()).isEqualTo(0.125f);
+        assertThat(
+                        fakeExtensions.createdNodes.stream()
+                                .map(FakeNode::getAlpha)
+                                .collect(Collectors.toList()))
+                .containsAtLeast(0.5f, 0.5f, 0.5f);
+    }
+
+    @Test
+    public void transformPoseTo_withScale_returnsPose() throws Exception {
+        PanelEntityImpl sourceEntity = (PanelEntityImpl) createPanelEntity();
+        GltfEntityImpl destinationEntity = (GltfEntityImpl) createGltfEntity();
+        sourceEntity.setPose(new Pose(new Vector3(0f, 0f, 1f), Quaternion.Identity));
+        sourceEntity.setScale(new Vector3(2f, 2f, 2f));
+        destinationEntity.setPose(new Pose(new Vector3(1f, 0f, 0f), Quaternion.Identity));
+        destinationEntity.setScale(new Vector3(3f, 3f, 3f));
+
+        Pose offsetFromSource = new Pose(new Vector3(0f, 0f, 1f), Quaternion.Identity);
+        assertPose(
+                sourceEntity.transformPoseTo(offsetFromSource, destinationEntity),
+                new Pose(new Vector3(-1 / 3f, 0f, 1f), Quaternion.Identity));
+    }
+
+    @Test
+    @Ignore("Flaky test, see b/380269912")
+    public void transformPoseTo_withNonUniformScalesAndTranslations_returnsPose() throws Exception {
+        PanelEntityImpl sourceEntity = (PanelEntityImpl) createPanelEntity();
+        GltfEntityImpl destinationEntity = (GltfEntityImpl) createGltfEntity();
+        sourceEntity.setPose(new Pose(new Vector3(0f, 0f, 1f), Quaternion.Identity));
+        sourceEntity.setScale(new Vector3(0.5f, 2f, -3f));
+        destinationEntity.setPose(new Pose(new Vector3(1f, 1f, 0f), Quaternion.Identity));
+        destinationEntity.setScale(new Vector3(4f, 5f, 6f));
+
+        Pose offsetFromSource = new Pose(new Vector3(1f, 3f, 1f), Quaternion.Identity);
+        // translation is:
+        //  ((localOffsetFromSource * scale of source) + sourceTranslation - destinationTranslation)
+        //    * (1/scale of  destination)
+        //
+        //  ((1, 3, 1) * (1/2, 2, -3) + (0, 0, 1) - (1, 1, 0)) * (1/4, 1/5, 1/6) =
+        //              ((1/2, 6, -3) + (0, 0, 1) - (1, 1, 0)) * (1/4, 1/5, 1/6) =
+        //                                       (-1/2, 5, -2) * (1/4, 1/5, 1/6) =
+        //                                                     (-1/8, 1, -2/6) = (-1/8, 1, -1/3)
+        assertPose(
+                sourceEntity.transformPoseTo(offsetFromSource, destinationEntity),
+                new Pose(new Vector3(-1 / 8f, 1f, -1 / 3f), Quaternion.Identity));
+    }
+
+    @Test
+    public void isHidden_returnsSetHidden() throws Exception {
+        PanelEntity parentEntity = createPanelEntity();
+        assertThat(parentEntity.isHidden(true)).isFalse();
+        assertThat(parentEntity.isHidden(false)).isFalse();
+
+        PanelEntity childEntity1 = createPanelEntity();
+        PanelEntity childEntity2 = createPanelEntity();
+        childEntity1.setParent(parentEntity);
+        childEntity2.setParent(childEntity1);
+
+        assertThat(childEntity1.isHidden(true)).isFalse();
+        assertThat(childEntity1.isHidden(false)).isFalse();
+
+        parentEntity.setHidden(true);
+        assertThat(parentEntity.isHidden(true)).isTrue();
+        assertThat(parentEntity.isHidden(false)).isTrue();
+        assertThat(childEntity1.isHidden(true)).isTrue();
+        assertThat(childEntity1.isHidden(false)).isFalse();
+        assertThat(childEntity2.isHidden(true)).isTrue();
+        assertThat(childEntity2.isHidden(false)).isFalse();
+
+        parentEntity.setHidden(false);
+        assertThat(parentEntity.isHidden(true)).isFalse();
+        assertThat(parentEntity.isHidden(false)).isFalse();
+        assertThat(childEntity1.isHidden(true)).isFalse();
+        assertThat(childEntity1.isHidden(false)).isFalse();
+        assertThat(childEntity2.isHidden(true)).isFalse();
+        assertThat(childEntity2.isHidden(false)).isFalse();
+
+        childEntity1.setHidden(true);
+        assertThat(parentEntity.isHidden(true)).isFalse();
+        assertThat(parentEntity.isHidden(false)).isFalse();
+        assertThat(childEntity1.isHidden(true)).isTrue();
+        assertThat(childEntity1.isHidden(false)).isTrue();
+        assertThat(childEntity2.isHidden(true)).isTrue();
+        assertThat(childEntity2.isHidden(false)).isFalse();
+    }
+
+    @Test
+    public void setHidden_modifiesReforms() throws Exception {
+        PanelEntity testEntity = createPanelEntity();
+        FakeNode testNode = (FakeNode) ((AndroidXrEntity) testEntity).getNode();
+
+        assertThat(
+                        testEntity.addComponent(
+                                realityCoreRuntime.createMovableComponent(
+                                        /* systemMovable= */ true,
+                                        /* scaleInZ= */ true,
+                                        /* anchorPlacement= */ ImmutableSet.of(),
+                                        /* shouldDisposeParentAnchor= */ true)))
+                .isTrue();
+        testEntity.setHidden(true);
+        assertThat(testNode.getReformOptions().getEnabledReform()).isEqualTo(0);
+        testEntity.setHidden(false);
+        assertThat(testNode.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_MOVE);
+    }
+
+    @Test
+    public void loggingEntityAddChildren() {
+        Pose pose = new Pose();
+        LoggingEntity childEntity1 = realityCoreRuntime.createLoggingEntity(pose);
+        LoggingEntity childEntity2 = realityCoreRuntime.createLoggingEntity(pose);
+        LoggingEntity parentEntity = realityCoreRuntime.createLoggingEntity(pose);
+
+        parentEntity.addChild(childEntity1);
+
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1);
+
+        parentEntity.addChildren(ImmutableList.of(childEntity2));
+
+        assertThat(childEntity1.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity2.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1, childEntity2);
+    }
+
+    @Test
+    public void getActivitySpace_returnsEntity() {
+        ActivitySpace activitySpace = realityCoreRuntime.getActivitySpace();
+
+        assertThat(activitySpace).isNotNull();
+        // Verify that there is an underlying extension node.
+        ActivitySpaceImpl activitySpaceImpl = (ActivitySpaceImpl) activitySpace;
+        assertThat(activitySpaceImpl.getNode()).isNotNull();
+    }
+
+    @Test
+    public void getActivitySpaceRootImpl_returnsEntity() {
+        Entity activitySpaceRoot = realityCoreRuntime.getActivitySpaceRootImpl();
+        assertThat(activitySpaceRoot).isNotNull();
+
+        // Verify that there is an underlying extension node.
+        AndroidXrEntity activitySpaceRootImpl = (AndroidXrEntity) activitySpaceRoot;
+        assertThat(activitySpaceRootImpl.getNode()).isNotNull();
+    }
+
+    @Test
+    public void getEnvironment_returnsEnvironment() {
+        SpatialEnvironment environment = realityCoreRuntime.getSpatialEnvironment();
+        assertThat(environment).isNotNull();
+    }
+
+    @Test
+    public void getHeadActivityPose_returnsNullIfNotReady() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getHeadPose()).thenReturn(null);
+        HeadActivityPose headActivityPose = realityCoreRuntime.getHeadActivityPose();
+
+        assertThat(headActivityPose).isNull();
+    }
+
+    @Test
+    public void getHeadActivityPose_returnsActivityPose() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getHeadPose())
+                .thenReturn(androidx.xr.scenecore.impl.perception.Pose.identity());
+        HeadActivityPose headActivityPose = realityCoreRuntime.getHeadActivityPose();
+
+        assertThat(headActivityPose).isNotNull();
+    }
+
+    @Test
+    public void getCameraViewActivityPose_returnsNullIfNotReady() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getStereoViews()).thenReturn(new ViewProjections(null, null));
+
+        CameraViewActivityPose leftCameraViewActivityPose =
+                realityCoreRuntime.getCameraViewActivityPose(
+                        CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+        CameraViewActivityPose rightCameraViewActivityPose =
+                realityCoreRuntime.getCameraViewActivityPose(
+                        CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+
+        assertThat(leftCameraViewActivityPose).isNull();
+        assertThat(rightCameraViewActivityPose).isNull();
+    }
+
+    @Test
+    public void getLeftCameraViewActivityPose_returnsActivityPose() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        ViewProjection viewProjection =
+                new ViewProjection(
+                        androidx.xr.scenecore.impl.perception.Pose.identity(), new Fov(0, 0, 0, 0));
+        when(session.getStereoViews())
+                .thenReturn(new ViewProjections(viewProjection, viewProjection));
+        CameraViewActivityPose cameraViewActivityPose =
+                realityCoreRuntime.getCameraViewActivityPose(
+                        CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE);
+
+        assertThat(cameraViewActivityPose).isNotNull();
+    }
+
+    @Test
+    public void getRightCameraViewActivityPose_returnsActivityPose() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        ViewProjection viewProjection =
+                new ViewProjection(
+                        androidx.xr.scenecore.impl.perception.Pose.identity(), new Fov(0, 0, 0, 0));
+        when(session.getStereoViews())
+                .thenReturn(new ViewProjections(viewProjection, viewProjection));
+        CameraViewActivityPose cameraViewActivityPose =
+                realityCoreRuntime.getCameraViewActivityPose(
+                        CameraViewActivityPose.CAMERA_TYPE_RIGHT_EYE);
+
+        assertThat(cameraViewActivityPose).isNotNull();
+    }
+
+    @Test
+    public void getUnknownCameraViewActivityPose_returnsEmptyOptional() {
+        CameraViewActivityPose cameraViewActivityPose =
+                realityCoreRuntime.getCameraViewActivityPose(555);
+
+        assertThat(cameraViewActivityPose).isNull();
+    }
+
+    @Test
+    public void loadExrImageByAssetName_returnsImage() throws Exception {
+        ListenableFuture<ExrImageResource> imageFuture =
+                realityCoreRuntime.loadExrImageByAssetName("FakeAsset.exr");
+
+        assertThat(imageFuture).isNotNull();
+
+        ExrImageResource image = imageFuture.get();
+        assertThat(image).isNotNull();
+        ExrImageResourceImpl imageImpl = (ExrImageResourceImpl) image;
+        assertThat(imageImpl).isNotNull();
+        FakeEnvironmentToken token = (FakeEnvironmentToken) imageImpl.getToken();
+        assertThat(token).isNotNull();
+        assertThat(token.getUrl()).isEqualTo("FakeAsset.exr");
+    }
+
+    @Test
+    public void loadGltfByAssetName_returnsModel() throws Exception {
+        ListenableFuture<GltfModelResource> modelFuture =
+                realityCoreRuntime.loadGltfByAssetName("FakeAsset.glb");
+
+        assertThat(modelFuture).isNotNull();
+
+        GltfModelResource model = modelFuture.get();
+        assertThat(model).isNotNull();
+        GltfModelResourceImpl modelImpl = (GltfModelResourceImpl) model;
+        assertThat(modelImpl).isNotNull();
+        FakeGltfModelToken token = (FakeGltfModelToken) modelImpl.getExtensionModelToken();
+        assertThat(token).isNotNull();
+        assertThat(token.getUrl()).isEqualTo("FakeAsset.glb");
+    }
+
+    @Test
+    public void createGltfEntity_returnsEntity() throws Exception {
+        assertThat(createGltfEntity()).isNotNull();
+    }
+
+    @Test
+    public void createGltfEntitySplitEngine_returnsEntity() throws Exception {
+        assertThat(createGltfEntitySplitEngine()).isNotNull();
+    }
+
+    @Test
+    public void animateGltfEntitySplitEngine_gltfEntityIsAnimating() throws Exception {
+        GltfEntity gltfEntitySplitEngine = createGltfEntitySplitEngine();
+        gltfEntitySplitEngine.startAnimation(false, "animation_name");
+        int animatingNodes = fakeImpressApi.impressNodeAnimatingSize();
+        int loopingAnimatingNodes = fakeImpressApi.impressNodeLoopAnimatingSize();
+
+        // The fakeJniApi returns a future which immediately fires, which makes it seem like the
+        // animation is done immediately. This makes it look like the animation stopped right away.
+        assertThat(gltfEntitySplitEngine.getAnimationState())
+                .isEqualTo(GltfEntity.AnimationState.PLAYING);
+        assertThat(animatingNodes).isEqualTo(1);
+        assertThat(loopingAnimatingNodes).isEqualTo(0);
+    }
+
+    @Test
+    public void animateLoopGltfEntitySplitEngine_gltfEntityIsAnimatingInLoop() throws Exception {
+        GltfEntity gltfEntitySplitEngine = createGltfEntitySplitEngine();
+        gltfEntitySplitEngine.startAnimation(true, "animation_name");
+        int animatingNodes = fakeImpressApi.impressNodeAnimatingSize();
+        int loopingAnimatingNodes = fakeImpressApi.impressNodeLoopAnimatingSize();
+
+        assertThat(gltfEntitySplitEngine.getAnimationState())
+                .isEqualTo(GltfEntity.AnimationState.PLAYING);
+        assertThat(animatingNodes).isEqualTo(0);
+        assertThat(loopingAnimatingNodes).isEqualTo(1);
+    }
+
+    @Test
+    public void stopAnimateGltfEntitySplitEngine_gltfEntityStopsAnimating() throws Exception {
+        GltfEntity gltfEntitySplitEngine = createGltfEntitySplitEngine();
+        gltfEntitySplitEngine.startAnimation(true, "animation_name");
+        gltfEntitySplitEngine.stopAnimation();
+        int animatingNodes = fakeImpressApi.impressNodeAnimatingSize();
+        int loopingAnimatingNodes = fakeImpressApi.impressNodeLoopAnimatingSize();
+
+        assertThat(gltfEntitySplitEngine.getAnimationState())
+                .isEqualTo(GltfEntity.AnimationState.STOPPED);
+        assertThat(animatingNodes).isEqualTo(0);
+        assertThat(loopingAnimatingNodes).isEqualTo(0);
+    }
+
+    @Test
+    public void gltfEntitySetParent() throws Exception {
+        GltfEntity childEntity = createGltfEntity();
+        GltfEntity parentEntity = createGltfEntity();
+
+        childEntity.setParent(parentEntity);
+
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getParent())
+                .isEqualTo(realityCoreRuntime.getActivitySpaceRootImpl());
+        assertThat(childEntity.getChildren()).isEmpty();
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity);
+
+        // Verify that there is an underlying extension node relationship.
+        FakeNode childNode = (FakeNode) ((GltfEntityImpl) childEntity).getNode();
+        assertThat(childNode.getParent()).isEqualTo(((GltfEntityImpl) parentEntity).getNode());
+    }
+
+    @Test
+    public void gltfEntityUpdateParent() throws Exception {
+        GltfEntity childEntity = createGltfEntity();
+        GltfEntity parentEntity1 = createGltfEntity();
+        GltfEntity parentEntity2 = createGltfEntity();
+
+        childEntity.setParent(parentEntity1);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity1);
+        assertThat(parentEntity1.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity2.getChildren()).isEmpty();
+
+        FakeNode childNode = (FakeNode) ((GltfEntityImpl) childEntity).getNode();
+        assertThat(childNode.getParent()).isEqualTo(((GltfEntityImpl) parentEntity1).getNode());
+
+        childEntity.setParent(parentEntity2);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity2);
+        assertThat(parentEntity2.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity1.getChildren()).isEmpty();
+        assertThat(childNode.getParent()).isEqualTo(((GltfEntityImpl) parentEntity2).getNode());
+    }
+
+    @Test
+    public void gltfEntityAddChildren() throws Exception {
+        GltfEntity childEntity1 = createGltfEntity();
+        GltfEntity childEntity2 = createGltfEntity();
+        GltfEntity parentEntity = createGltfEntity();
+
+        parentEntity.addChild(childEntity1);
+
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1);
+
+        parentEntity.addChildren(ImmutableList.of(childEntity2));
+
+        assertThat(childEntity1.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity2.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1, childEntity2);
+
+        FakeNode childNode1 = (FakeNode) ((GltfEntityImpl) childEntity1).getNode();
+        assertThat(childNode1.getParent()).isEqualTo(((GltfEntityImpl) parentEntity).getNode());
+        FakeNode childNode2 = (FakeNode) ((GltfEntityImpl) childEntity2).getNode();
+        assertThat(childNode2.getParent()).isEqualTo(((GltfEntityImpl) parentEntity).getNode());
+    }
+
+    @Test
+    public void createPanelEntity_returnsEntity() throws Exception {
+        assertThat(createPanelEntity()).isNotNull();
+    }
+
+    @Test
+    public void allPanelEnities_haveActivitySpaceRootImplAsParentByDefault() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+
+        assertThat(panelEntity.getParent())
+                .isEqualTo(realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    @Test
+    public void panelEntitySetParent_setsParent() throws Exception {
+        PanelEntity childEntity = createPanelEntity();
+        PanelEntity parentEntity = createPanelEntity();
+
+        childEntity.setParent(parentEntity);
+
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity.getChildren()).isEmpty();
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity);
+
+        // Verify that there is an underlying extension node relationship.
+        FakeNode childNode = (FakeNode) ((PanelEntityImpl) childEntity).getNode();
+        assertThat(childNode.getParent()).isEqualTo(((PanelEntityImpl) parentEntity).getNode());
+    }
+
+    @Test
+    public void panelEntityUpdateParent_updatesParent() throws Exception {
+        PanelEntity childEntity = createPanelEntity();
+        PanelEntity parentEntity1 = createPanelEntity();
+        PanelEntity parentEntity2 = createPanelEntity();
+
+        childEntity.setParent(parentEntity1);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity1);
+        assertThat(parentEntity1.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity2.getChildren()).isEmpty();
+
+        FakeNode childNode = (FakeNode) ((PanelEntityImpl) childEntity).getNode();
+        assertThat(childNode.getParent()).isEqualTo(((PanelEntityImpl) parentEntity1).getNode());
+
+        childEntity.setParent(parentEntity2);
+        assertThat(childEntity.getParent()).isEqualTo(parentEntity2);
+        assertThat(parentEntity2.getChildren()).containsExactly(childEntity);
+        assertThat(parentEntity1.getChildren()).isEmpty();
+        assertThat(childNode.getParent()).isEqualTo(((PanelEntityImpl) parentEntity2).getNode());
+    }
+
+    @Test
+    public void panelEntityAddChildren_addsChildren() throws Exception {
+        PanelEntity childEntity1 = createPanelEntity();
+        PanelEntity childEntity2 = createPanelEntity();
+        PanelEntity parentEntity = createPanelEntity();
+
+        parentEntity.addChild(childEntity1);
+
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1);
+
+        parentEntity.addChildren(ImmutableList.of(childEntity2));
+
+        assertThat(childEntity1.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity2.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1, childEntity2);
+
+        FakeNode childNode1 = (FakeNode) ((PanelEntityImpl) childEntity1).getNode();
+        assertThat(childNode1.getParent()).isEqualTo(((PanelEntityImpl) parentEntity).getNode());
+        FakeNode childNode2 = (FakeNode) ((PanelEntityImpl) childEntity2).getNode();
+        assertThat(childNode2.getParent()).isEqualTo(((PanelEntityImpl) parentEntity).getNode());
+    }
+
+    @Test
+    public void createAnchorEntity_returnsAndInitsAnchor() throws Exception {
+        Dimensions anchorDimensions = new Dimensions(2f, 5f, 0f);
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                androidx.xr.scenecore.impl.perception.Pose.identity();
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any()))
+                .thenReturn(
+                        new Plane.PlaneData(
+                                perceptionPose,
+                                3.0f,
+                                5.0f,
+                                Plane.Type.VERTICAL.intValue,
+                                Plane.Label.WALL.intValue));
+        when(plane.createAnchor(eq(perceptionPose), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        AnchorEntity anchorEntity =
+                realityCoreRuntime.createAnchorEntity(
+                        anchorDimensions, PlaneType.VERTICAL, PlaneSemantic.WALL, Duration.ZERO);
+
+        assertThat(anchorEntity).isNotNull();
+        assertThat(anchorEntity.getState()).isEqualTo(State.ANCHORED);
+    }
+
+    @Test
+    public void getMainPanelEntity_returnsPanelEntity() throws Exception {
+        assertThat(realityCoreRuntime.getMainPanelEntity()).isNotNull();
+    }
+
+    @Test
+    public void getMainPanelEntity_usesWindowLeashNode() throws Exception {
+        PanelEntity mainPanel = realityCoreRuntime.getMainPanelEntity();
+
+        assertThat(((MainPanelEntityImpl) mainPanel).getNode())
+                .isEqualTo(fakeExtensions.getFakeNodeForMainWindow());
+    }
+
+    @Test
+    public void addInputEventConsumerToEntity_setsUpNodeListener() {
+        InputEventListener mockConsumer = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+
+        assertThat(node.getListener()).isNotNull();
+        assertThat(node.getExecutor()).isEqualTo(fakeExecutor);
+
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        verify(mockConsumer).onInputEvent(any());
+    }
+
+    @Test
+    public void inputEvent_hasHitInfo() {
+        InputEventListener mockConsumer = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent xrInputEvent = new FakeInputEvent();
+        xrInputEvent.setOrigin(new Vec3(0, 0, 0));
+        xrInputEvent.setDirection(new Vec3(1, 1, 1));
+        FakeHitInfo hitInfo = new FakeHitInfo();
+        hitInfo.setInputNode(node);
+        hitInfo.setHitPosition(new Vec3(1, 2, 3));
+        hitInfo.setTransform(new Mat4f(new float[16]));
+        xrInputEvent.setFakeHitInfo(hitInfo);
+
+        node.sendInputEvent(xrInputEvent);
+        fakeExecutor.runAll();
+
+        ArgumentCaptor<InputEvent> inputEventCaptor = ArgumentCaptor.forClass(InputEvent.class);
+        verify(mockConsumer).onInputEvent(inputEventCaptor.capture());
+        InputEvent capturedEvent = inputEventCaptor.getValue();
+        assertThat(capturedEvent.hitInfo).isNotNull();
+        assertThat(capturedEvent.hitInfo.inputEntity).isEqualTo(panelEntity);
+        assertThat(capturedEvent.hitInfo.hitPosition).isEqualTo(new Vector3(1, 2, 3));
+    }
+
+    @Test
+    public void passingNullExecutorWhenAddingConsumer_usesInternalExecutor() {
+        InputEventListener mockConsumer = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        panelEntity.addInputEventListener(/* executor= */ null, mockConsumer);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+
+        assertThat(node.getListener()).isNotNull();
+        assertThat(node.getExecutor()).isNotNull();
+    }
+
+    @Test
+    public void addMultipleInputEventConsumerToEntity_setsUpInputCallbacksForAll() {
+        InputEventListener mockConsumer1 = mock(InputEventListener.class);
+        InputEventListener mockConsumer2 = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer1);
+        panelEntity.addInputEventListener(executor, mockConsumer2);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        verify(mockConsumer1).onInputEvent(any());
+        verify(mockConsumer2).onInputEvent(any());
+    }
+
+    @Test
+    public void addMultipleInputEventConsumersToEntity_setsUpInputCallbacksOnGivenExecutors() {
+        InputEventListener mockConsumer1 = mock(InputEventListener.class);
+        InputEventListener mockConsumer2 = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        FakeScheduledExecutorService executor1 = new FakeScheduledExecutorService();
+        FakeScheduledExecutorService executor2 = new FakeScheduledExecutorService();
+        panelEntity.addInputEventListener(executor1, mockConsumer1);
+        panelEntity.addInputEventListener(executor2, mockConsumer2);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        assertThat(executor1.hasNext()).isTrue();
+        assertThat(executor2.hasNext()).isTrue();
+
+        executor1.runAll();
+        executor2.runAll();
+
+        verify(mockConsumer1).onInputEvent(any());
+        verify(mockConsumer2).onInputEvent(any());
+    }
+
+    @Test
+    public void removeInputEventConsumerToEntity_removesFromCallbacks() {
+        InputEventListener mockConsumer1 = mock(InputEventListener.class);
+        InputEventListener mockConsumer2 = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer1);
+        panelEntity.addInputEventListener(executor, mockConsumer2);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        panelEntity.removeInputEventListener(mockConsumer1);
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        verify(mockConsumer2, times(2)).onInputEvent(any());
+        verify(mockConsumer1).onInputEvent(any());
+    }
+
+    @Test
+    public void removeAllInputEventConsumers_stopsInputListening() {
+        InputEventListener mockConsumer1 = mock(InputEventListener.class);
+        InputEventListener mockConsumer2 = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer1);
+        panelEntity.addInputEventListener(executor, mockConsumer2);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        verify(mockConsumer1).onInputEvent(any());
+        verify(mockConsumer2).onInputEvent(any());
+
+        panelEntity.removeInputEventListener(mockConsumer1);
+        panelEntity.removeInputEventListener(mockConsumer2);
+
+        assertThat(((PanelEntityImpl) panelEntity).inputEventListenerMap).isEmpty();
+        assertThat(node.getListener()).isNull();
+        assertThat(node.getExecutor()).isNull();
+    }
+
+    @Test
+    public void dispose_stopsInputListening() {
+        InputEventListener mockConsumer1 = mock(InputEventListener.class);
+        InputEventListener mockConsumer2 = mock(InputEventListener.class);
+        PanelEntity panelEntity = createPanelEntity();
+        Executor executor = directExecutor();
+        panelEntity.addInputEventListener(executor, mockConsumer1);
+        panelEntity.addInputEventListener(executor, mockConsumer2);
+        FakeNode node = (FakeNode) ((PanelEntityImpl) panelEntity).getNode();
+        FakeInputEvent inputEvent = new FakeInputEvent();
+        inputEvent.setOrigin(new Vec3(0, 0, 0));
+        inputEvent.setDirection(new Vec3(1, 1, 1));
+
+        node.sendInputEvent(inputEvent);
+        fakeExecutor.runAll();
+
+        verify(mockConsumer1).onInputEvent(any());
+        verify(mockConsumer2).onInputEvent(any());
+
+        panelEntity.dispose();
+
+        assertThat(((PanelEntityImpl) panelEntity).inputEventListenerMap).isEmpty();
+        assertThat(node.getListener()).isNull();
+        assertThat(node.getExecutor()).isNull();
+    }
+
+    @Test
+    public void createContentlessEntity_returnsEntity() throws Exception {
+        assertThat(createContentlessEntity()).isNotNull();
+    }
+
+    @Test
+    public void contentlessEntity_hasActivitySpaceRootImplAsParentByDefault() throws Exception {
+        Entity entity = createContentlessEntity();
+        assertThat(entity.getParent()).isEqualTo(realityCoreRuntime.getActivitySpaceRootImpl());
+    }
+
+    @Test
+    public void contentlessEntityAddChildren_addsChildren() throws Exception {
+        Entity childEntity1 = createContentlessEntity();
+        Entity childEntity2 = createContentlessEntity();
+        Entity parentEntity = createContentlessEntity();
+
+        parentEntity.addChild(childEntity1);
+
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1);
+
+        parentEntity.addChildren(ImmutableList.of(childEntity2));
+
+        assertThat(childEntity1.getParent()).isEqualTo(parentEntity);
+        assertThat(childEntity2.getParent()).isEqualTo(parentEntity);
+        assertThat(parentEntity.getChildren()).containsExactly(childEntity1, childEntity2);
+
+        FakeNode childNode1 = (FakeNode) ((AndroidXrEntity) childEntity1).getNode();
+        assertThat(childNode1.getParent()).isEqualTo(((AndroidXrEntity) parentEntity).getNode());
+        FakeNode childNode2 = (FakeNode) ((AndroidXrEntity) childEntity2).getNode();
+        assertThat(childNode2.getParent()).isEqualTo(((AndroidXrEntity) parentEntity).getNode());
+    }
+
+    @Test
+    public void addComponent_callsOnAttach() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component = mock(Component.class);
+        when(component.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(loggingEntity);
+    }
+
+    @Test
+    public void addComponent_failsIfOnAttachFails() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component = mock(Component.class);
+        when(component.onAttach(any())).thenReturn(false);
+
+        assertThat(panelEntity.addComponent(component)).isFalse();
+        verify(component).onAttach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component)).isFalse();
+        verify(component).onAttach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component)).isFalse();
+        verify(component).onAttach(loggingEntity);
+    }
+
+    @Test
+    public void removeComponent_callsOnDetach() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component = mock(Component.class);
+        when(component.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(panelEntity);
+
+        panelEntity.removeComponent(component);
+        verify(component).onDetach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(gltfEntity);
+
+        gltfEntity.removeComponent(component);
+        verify(component).onDetach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(loggingEntity);
+
+        loggingEntity.removeComponent(component);
+        verify(component).onDetach(loggingEntity);
+    }
+
+    @Test
+    public void addingSameComponentTypeAgain_addsComponent() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component1 = mock(Component.class);
+        Component component2 = mock(Component.class);
+        when(component1.onAttach(any())).thenReturn(true);
+        when(component2.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component1)).isTrue();
+        assertThat(panelEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(panelEntity);
+        verify(component2).onAttach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component1)).isTrue();
+        assertThat(gltfEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(gltfEntity);
+        verify(component2).onAttach(panelEntity);
+
+        assertThat(loggingEntity.addComponent(component1)).isTrue();
+        assertThat(loggingEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(loggingEntity);
+        verify(component2).onAttach(panelEntity);
+    }
+
+    @Test
+    public void addingDifferentComponentType_addComponentSucceeds() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component1 = mock(Component.class);
+        Component component2 = mock(FakeComponent.class);
+        when(component1.onAttach(any())).thenReturn(true);
+        when(component2.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component1)).isTrue();
+        assertThat(panelEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(panelEntity);
+        verify(component2).onAttach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component1)).isTrue();
+        assertThat(gltfEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(gltfEntity);
+        verify(component2).onAttach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component1)).isTrue();
+        assertThat(loggingEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(loggingEntity);
+        verify(component2).onAttach(loggingEntity);
+    }
+
+    @Test
+    public void removeAll_callsOnDetachOnAll() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component1 = mock(Component.class);
+        Component component2 = mock(FakeComponent.class);
+        when(component1.onAttach(any())).thenReturn(true);
+        when(component2.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component1)).isTrue();
+        assertThat(panelEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(panelEntity);
+        verify(component2).onAttach(panelEntity);
+
+        panelEntity.removeAllComponents();
+        verify(component1).onDetach(panelEntity);
+        verify(component2).onDetach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component1)).isTrue();
+        assertThat(gltfEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(gltfEntity);
+        verify(component2).onAttach(gltfEntity);
+
+        gltfEntity.removeAllComponents();
+        verify(component1).onDetach(gltfEntity);
+        verify(component2).onDetach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component1)).isTrue();
+        assertThat(loggingEntity.addComponent(component2)).isTrue();
+        verify(component1).onAttach(loggingEntity);
+        verify(component2).onAttach(loggingEntity);
+
+        loggingEntity.removeAllComponents();
+        verify(component1).onDetach(loggingEntity);
+        verify(component2).onDetach(loggingEntity);
+    }
+
+    @Test
+    public void addSameComponentTwice_callsOnAttachTwice() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component = mock(Component.class);
+        when(component.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component)).isTrue();
+        assertThat(panelEntity.addComponent(component)).isTrue();
+        verify(component, times(2)).onAttach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component)).isTrue();
+        assertThat(gltfEntity.addComponent(component)).isTrue();
+        verify(component, times(2)).onAttach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component)).isTrue();
+        assertThat(loggingEntity.addComponent(component)).isTrue();
+        verify(component, times(2)).onAttach(loggingEntity);
+    }
+
+    @Test
+    public void removeSameComponentTwice_callsOnDetachOnce() throws Exception {
+        PanelEntity panelEntity = createPanelEntity();
+        GltfEntity gltfEntity = createGltfEntity();
+        LoggingEntity loggingEntity = realityCoreRuntime.createLoggingEntity(new Pose());
+        Component component = mock(Component.class);
+        when(component.onAttach(any())).thenReturn(true);
+
+        assertThat(panelEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(panelEntity);
+
+        panelEntity.removeComponent(component);
+        panelEntity.removeComponent(component);
+        verify(component).onDetach(panelEntity);
+
+        assertThat(gltfEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(gltfEntity);
+
+        gltfEntity.removeComponent(component);
+        gltfEntity.removeComponent(component);
+        verify(component).onDetach(gltfEntity);
+
+        assertThat(loggingEntity.addComponent(component)).isTrue();
+        verify(component).onAttach(loggingEntity);
+
+        loggingEntity.removeComponent(component);
+        loggingEntity.removeComponent(component);
+        verify(component).onDetach(loggingEntity);
+    }
+
+    @Test
+    public void createInteractableComponent_returnsComponent() {
+        InputEventListener mockConsumer = mock(InputEventListener.class);
+        InteractableComponent interactableComponent =
+                realityCoreRuntime.createInteractableComponent(directExecutor(), mockConsumer);
+        assertThat(interactableComponent).isNotNull();
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_returnsEntityInNominalCase() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(any())).thenReturn(anchor);
+        assertThat(
+                        realityCoreRuntime.createPersistedAnchorEntity(
+                                UUID.randomUUID(), /* searchTimeout= */ Duration.ofSeconds(1)))
+                .isNotNull();
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_returnsEntityForNullSession() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+        assertThat(
+                        realityCoreRuntime.createPersistedAnchorEntity(
+                                UUID.randomUUID(), /* searchTimeout= */ Duration.ofSeconds(1)))
+                .isNotNull();
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_returnsEntityForNullAnchor() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(any())).thenReturn(null);
+        assertThat(
+                        realityCoreRuntime.createPersistedAnchorEntity(
+                                UUID.randomUUID(), /* searchTimeout= */ Duration.ofSeconds(1)))
+                .isNotNull();
+    }
+
+    @Test
+    public void createPersistedAnchorEntity_returnsEntityForNullAnchorToken() throws Exception {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.createAnchorFromUuid(any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(null);
+        UUID uuid = UUID.randomUUID();
+        assertThat(
+                        realityCoreRuntime.createPersistedAnchorEntity(
+                                uuid, /* searchTimeout= */ Duration.ofSeconds(1)))
+                .isNotNull();
+        verify(perceptionLibrary, times(3)).getSession();
+        verify(session).createAnchorFromUuid(uuid);
+        verify(anchor).getAnchorToken();
+    }
+
+    @Test
+    public void unpersistAnchor_failsWhenSessionIsNotInitialized() {
+        when(perceptionLibrary.getSession()).thenReturn(null);
+        assertThat(realityCoreRuntime.unpersistAnchor(UUID.randomUUID())).isFalse();
+    }
+
+    @Test
+    public void unpersistAnchor_sessionIsInitialized_operationSucceeds() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        UUID uuid = UUID.randomUUID();
+        when(session.unpersistAnchor(uuid)).thenReturn(true);
+        assertThat(realityCoreRuntime.unpersistAnchor(uuid)).isTrue();
+    }
+
+    @Test
+    public void unpersistAnchor_sessionIsInitialized_operationFails() {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        UUID uuid = UUID.randomUUID();
+        when(session.unpersistAnchor(uuid)).thenReturn(false);
+        assertThat(realityCoreRuntime.unpersistAnchor(uuid)).isFalse();
+    }
+
+    @Test
+    public void createMovableComponent_returnsComponent() {
+        MovableComponent movableComponent =
+                realityCoreRuntime.createMovableComponent(
+                        true, true, new HashSet<AnchorPlacement>(), true);
+        assertThat(movableComponent).isNotNull();
+    }
+
+    @Test
+    public void createAnchorPlacement_returnsAnchorPlacement() {
+        AnchorPlacement anchorPlacement =
+                realityCoreRuntime.createAnchorPlacementForPlanes(
+                        ImmutableSet.of(PlaneType.ANY), ImmutableSet.of(PlaneSemantic.ANY));
+        assertThat(anchorPlacement).isNotNull();
+    }
+
+    @Test
+    public void createResizableComponent_returnsComponent() {
+        ResizableComponent resizableComponent =
+                realityCoreRuntime.createResizableComponent(
+                        new Dimensions(0f, 0f, 0f), new Dimensions(5f, 5f, 5f));
+        assertThat(resizableComponent).isNotNull();
+    }
+
+    @Test
+    public void createPointerCaptureComponent_returnsComponent() {
+        PointerCaptureComponent pointerCaptureComponent =
+                realityCoreRuntime.createPointerCaptureComponent(
+                        null, (inputEvent) -> {}, (state) -> {});
+        assertThat(pointerCaptureComponent).isNotNull();
+    }
+
+    @Test
+    public void dispose_clearsReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createContentlessEntity();
+        FakeNode node = (FakeNode) entity.getNode();
+        ReformOptions reformOptions = entity.getReformOptions();
+        assertThat(reformOptions).isNotNull();
+        reformOptions.setEnabledReform(ReformOptions.ALLOW_MOVE | ReformOptions.ALLOW_RESIZE);
+        entity.dispose();
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(0);
+        assertThat(node.getReformOptions().getEventCallback()).isNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNull();
+    }
+
+    @Test
+    public void dispose_clearsParents() {
+        AndroidXrEntity entity = (AndroidXrEntity) createContentlessEntity();
+        entity.setParent(realityCoreRuntime.getActivitySpaceRootImpl());
+        assertThat(entity.getParent()).isNotNull();
+
+        entity.dispose();
+        assertThat(entity.getParent()).isNull();
+    }
+
+    @Test
+    public void setFullSpaceMode_callsExtensions() {
+        Bundle bundle = Bundle.EMPTY;
+        bundle = realityCoreRuntime.setFullSpaceMode(bundle);
+        assertThat(bundle).isNotNull();
+    }
+
+    @Test
+    public void setFullSpaceModeWithEnvironmentInherited_callsExtensions() {
+        Bundle bundle = Bundle.EMPTY;
+        bundle = realityCoreRuntime.setFullSpaceModeWithEnvironmentInherited(bundle);
+        assertThat(bundle).isNotNull();
+    }
+
+    @Test
+    public void setPreferredAspectRatio_callsExtensions() {
+        realityCoreRuntime.setPreferredAspectRatio(activity, 1.23f);
+        assertThat(fakeExtensions.getPreferredAspectRatio()).isEqualTo(1.23f);
+    }
+
+    @Test
+    public void createStereoSurface_returnsStereoSurface() {
+        // Not a great test, since it returns the (non-SplitEngine) StereoSurfaceEntityImpl
+        // and that throws this from its Ctor.
+        // TODO: b/366588688 - Properly test this path once SplitEngine is fully enabled.
+        assertThrows(
+                UnsupportedOperationException.class,
+                () ->
+                        realityCoreRuntime.createStereoSurfaceEntity(
+                                StereoSurfaceEntity.StereoMode.SIDE_BY_SIDE,
+                                new Dimensions(1.0f, 1.0f, 1.0f),
+                                new Pose(),
+                                realityCoreRuntime.getActivitySpaceRootImpl()));
+    }
+
+    @Test
+    public void getSurfaceFromStereoSurface_returnsSurface() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> fakeImpressApi.getSurfaceFromStereoSurface(1));
+    }
+
+    @Test
+    public void setStereoModeForStereoSurface_callsExtensions() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        fakeImpressApi.setStereoModeForStereoSurface(
+                                1, StereoSurfaceEntity.StereoMode.SIDE_BY_SIDE));
+    }
+
+    @Test
+    public void injectRootNodeAndTaskWindowLeashNode_runtimeImplUsesThoseNodes() {
+        FakeNode rootNode = (FakeNode) fakeExtensions.createNode();
+        FakeNode taskWindowLeashNode = (FakeNode) fakeExtensions.createNode();
+        JxrPlatformAdapterAxr runtime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        rootNode,
+                        taskWindowLeashNode,
+                        /* useSplitEngine= */ false);
+
+        assertThat(((AndroidXrEntity) runtime.getActivitySpace()).getNode()).isEqualTo(rootNode);
+        assertThat(((AndroidXrEntity) runtime.getMainPanelEntity()).getNode())
+                .isEqualTo(taskWindowLeashNode);
+    }
+
+    @Test
+    public void dispose_clearsResources() {
+        AndroidXrEntity entity = (AndroidXrEntity) createContentlessEntity();
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node).isNotNull();
+        assertThat(node.getParent()).isNotNull();
+
+        realityCoreRuntime.dispose();
+        assertThat(node.getParent()).isNull();
+        assertThat(fakeExtensions.getSpatialStateCallback()).isNull();
+        assertThat(fakeExtensions.getFakeNodeForMainWindow()).isNull();
+        assertThat(fakeExtensions.getFakeTaskNode()).isNull();
+    }
+
+    interface FakeComponent extends Component {}
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MainPanelEntityImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MainPanelEntityImplTest.java
new file mode 100644
index 0000000..b643154
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MainPanelEntityImplTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+@RunWith(RobolectricTestRunner.class)
+public class MainPanelEntityImplTest {
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity hostActivity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = Mockito.mock(PerceptionLibrary.class);
+    SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    ImpSplitEngineRenderer splitEngineRenderer = Mockito.mock(ImpSplitEngineRenderer.class);
+    private JxrPlatformAdapterAxr testRuntime;
+    private MainPanelEntityImpl mainPanelEntity;
+
+    @Before
+    public void setUp() {
+        when(perceptionLibrary.initSession(eq(hostActivity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(Mockito.mock(Session.class)));
+
+        testRuntime =
+                JxrPlatformAdapterAxr.create(
+                        hostActivity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+
+        mainPanelEntity = (MainPanelEntityImpl) testRuntime.getMainPanelEntity();
+    }
+
+    @Test
+    public void runtimeGetMainPanelEntity_returnsPanelEntityImpl() {
+        assertThat(mainPanelEntity).isNotNull();
+    }
+
+    @Test
+    public void mainPanelEntitySetPixelDimensions_callsExtensions() {
+        PixelDimensions kTestPixelDimensions = new PixelDimensions(14, 14);
+        mainPanelEntity.setPixelDimensions(kTestPixelDimensions);
+        assertThat(fakeExtensions.getMainWindowWidth()).isEqualTo(kTestPixelDimensions.width);
+        assertThat(fakeExtensions.getMainWindowHeight()).isEqualTo(kTestPixelDimensions.height);
+    }
+
+    @Test
+    public void mainPanelEntitySetSize_callsExtensions() {
+        // TODO(b/352630025): remove this once setSize is removed.
+        // This should have the same effect as setPixelDimensions, except that it has to convert
+        // from Dimensions to PixelDimensions, so it casts float to int.
+        Dimensions kTestDimensions = new Dimensions(123.0f, 123.0f, 123.0f);
+        mainPanelEntity.setSize(kTestDimensions);
+        assertThat(fakeExtensions.getMainWindowWidth()).isEqualTo((int) kTestDimensions.width);
+        assertThat(fakeExtensions.getMainWindowWidth()).isEqualTo((int) kTestDimensions.height);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/Matrix4ExtTest.kt b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/Matrix4ExtTest.kt
new file mode 100644
index 0000000..9b53a03
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/Matrix4ExtTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.xr.runtime.math.Matrix4
+import androidx.xr.runtime.math.Quaternion
+import androidx.xr.runtime.math.Vector3
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class Matrix4ExtTest {
+    @Test
+    fun unscaled_returnsUnscaledMatrix() {
+        // Column major, right handed 4x4 Transformation Matrix with
+        // translation of (4, 8, 12) and rotation 90 (@) around Z axis, and scale of 3.3.
+        // --     cos(@),   sin(@), 0,  0
+        // --    -sin(@),  cos(@), 0,  0
+        // --     0,        0,      1,  0
+        // --      tx,       ty,     tz, 1
+        val underTest =
+            Matrix4(
+                floatArrayOf(0f, 3.3f, 0f, 0f, -3.3f, 0f, 0f, 0f, 0f, 0f, 3.3f, 0f, 4f, 8f, 12f, 1f)
+            )
+        val underTestUnscaled = underTest.getUnscaled()
+        val expected =
+            Matrix4(floatArrayOf(0f, 1f, 0f, 0f, -1f, 0f, 0f, 0f, 0f, 0f, 1f, 0f, 4f, 8f, 12f, 1f))
+        assertMatrix(underTestUnscaled, expected)
+    }
+
+    @Test
+    fun unscaled_withNonUniformScale_returnsUnscaledMatrix() {
+        val underTest =
+            Matrix4.fromTrs(Vector3(1f, 2f, 3f), Quaternion(1f, 2f, 3f, 4f), Vector3(2f, 3f, 4f))
+        val underTestUnscaled = underTest.getUnscaled()
+        assertMatrix(
+            underTestUnscaled,
+            Matrix4.fromTrs(Vector3(1f, 2f, 3f), Quaternion(1f, 2f, 3f, 4f), Vector3(1f, 1f, 1f)),
+        )
+    }
+
+    @Test
+    fun unscaled_withIdentityTranslationAndRotation_returnsUnscaledMatrix() {
+        val underTest = Matrix4.fromTrs(Vector3.Zero, Quaternion.Identity, Vector3(2f, 3f, 4f))
+        val underTestUnscaled = underTest.getUnscaled()
+        assertMatrix(
+            underTestUnscaled,
+            Matrix4.fromTrs(Vector3.Zero, Quaternion.Identity, Vector3(1f, 1f, 1f)),
+        )
+    }
+
+    private fun assertMatrix(matrix: Matrix4, expected: Matrix4) {
+        assertThat(matrix.data.size).isEqualTo(expected.data.size)
+        for (i in matrix.data.indices) {
+            assertThat(matrix.data[i]).isWithin(1e-5f).of(expected.data[i])
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImplTest.java
new file mode 100644
index 0000000..7d8aaac
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaPlayerExtensionsWrapperImplTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.media.MediaPlayer;
+
+import androidx.xr.extensions.media.SpatializerExtensions;
+import androidx.xr.extensions.media.XrSpatialAudioExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.MediaPlayerExtensionsWrapper;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeMediaPlayerExtensions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class MediaPlayerExtensionsWrapperImplTest {
+    FakeXrExtensions fakeXrExtensions;
+    XrSpatialAudioExtensions spatialAudioExtensions;
+    FakeMediaPlayerExtensions fakeMediaPlayerExtensions;
+
+    @Before
+    public void setUp() {
+        fakeXrExtensions = new FakeXrExtensions();
+        spatialAudioExtensions = fakeXrExtensions.fakeSpatialAudioExtensions;
+        fakeMediaPlayerExtensions =
+                (FakeMediaPlayerExtensions) spatialAudioExtensions.getMediaPlayerExtensions();
+    }
+
+    @Test
+    public void setPointSourceAttr_callsExtensionsSetPointSourceAttr() {
+        MediaPlayer mediaPlayer = new MediaPlayer();
+
+        Node fakeNode = new FakeXrExtensions().createNode();
+        AndroidXrEntity entity = mock(AndroidXrEntity.class);
+        when(entity.getNode()).thenReturn(fakeNode);
+
+        JxrPlatformAdapter.PointSourceAttributes expectedRtAttr =
+                new JxrPlatformAdapter.PointSourceAttributes(entity);
+
+        MediaPlayerExtensionsWrapper wrapper =
+                new MediaPlayerExtensionsWrapperImpl(fakeMediaPlayerExtensions);
+        wrapper.setPointSourceAttributes(mediaPlayer, expectedRtAttr);
+
+        assertThat(fakeMediaPlayerExtensions.getPointSourceAttributes().getNode())
+                .isEqualTo(fakeNode);
+    }
+
+    @Test
+    public void setSoundFieldAttr_callsExtensionsSetSoundFieldAttr() {
+        MediaPlayer mediaPlayer = new MediaPlayer();
+
+        int expectedAmbisonicOrder = SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER;
+        JxrPlatformAdapter.SoundFieldAttributes expectedRtAttr =
+                new JxrPlatformAdapter.SoundFieldAttributes(
+                        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER);
+
+        MediaPlayerExtensionsWrapper wrapper =
+                new MediaPlayerExtensionsWrapperImpl(fakeMediaPlayerExtensions);
+        wrapper.setSoundFieldAttributes(mediaPlayer, expectedRtAttr);
+
+        assertThat(fakeMediaPlayerExtensions.getSoundFieldAttributes().getAmbisonicsOrder())
+                .isEqualTo(expectedAmbisonicOrder);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaUtilsTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaUtilsTest.java
new file mode 100644
index 0000000..213e4b1
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MediaUtilsTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import androidx.xr.extensions.media.PointSourceAttributes;
+import androidx.xr.extensions.media.SoundFieldAttributes;
+import androidx.xr.extensions.media.SpatializerExtensions;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatializerConstants;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class MediaUtilsTest {
+
+    @Test
+    public void convertPointSourceAttributes_returnsExtensionsAttributes() {
+        Node expected = new FakeXrExtensions().createNode();
+
+        AndroidXrEntity entity = mock(AndroidXrEntity.class);
+        when(entity.getNode()).thenReturn(expected);
+        JxrPlatformAdapter.PointSourceAttributes rtAttributes =
+                new JxrPlatformAdapter.PointSourceAttributes(entity);
+
+        PointSourceAttributes result =
+                MediaUtils.convertPointSourceAttributesToExtensions(rtAttributes);
+
+        assertThat(result.getNode()).isSameInstanceAs(expected);
+    }
+
+    @Test
+    public void convertSoundFieldAttributes_returnsExtensionsAttributes() {
+        int extAmbisonicsOrder = SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER;
+
+        JxrPlatformAdapter.SoundFieldAttributes rtAttributes =
+                new JxrPlatformAdapter.SoundFieldAttributes(
+                        SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER);
+
+        SoundFieldAttributes result =
+                MediaUtils.convertSoundFieldAttributesToExtensions(rtAttributes);
+
+        assertThat(result.getAmbisonicsOrder()).isEqualTo(extAmbisonicsOrder);
+    }
+
+    @Test
+    public void convertAmbisonicsOrderToExtensions_returnsExtensionsAmbisonicsOrder() {
+        assertThat(
+                        MediaUtils.convertAmbisonicsOrderToExtensions(
+                                SpatializerConstants.AMBISONICS_ORDER_FIRST_ORDER))
+                .isEqualTo(SpatializerExtensions.AMBISONICS_ORDER_FIRST_ORDER);
+        assertThat(
+                        MediaUtils.convertAmbisonicsOrderToExtensions(
+                                SpatializerConstants.AMBISONICS_ORDER_SECOND_ORDER))
+                .isEqualTo(SpatializerExtensions.AMBISONICS_ORDER_SECOND_ORDER);
+        assertThat(
+                        MediaUtils.convertAmbisonicsOrderToExtensions(
+                                SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER))
+                .isEqualTo(SpatializerExtensions.AMBISONICS_ORDER_THIRD_ORDER);
+    }
+
+    @Test
+    public void convertAmbisonicsOrderToExtensions_throwsExceptionForInvalidValue() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> MediaUtils.convertAmbisonicsOrderToExtensions(100));
+    }
+
+    @Test
+    public void convertExtensionsToSourceType_returnsRtSourceType() {
+        assertThat(
+                        MediaUtils.convertExtensionsToSourceType(
+                                SpatializerExtensions.SOURCE_TYPE_BYPASS))
+                .isEqualTo(SpatializerConstants.SOURCE_TYPE_BYPASS);
+        assertThat(
+                        MediaUtils.convertExtensionsToSourceType(
+                                SpatializerExtensions.SOURCE_TYPE_POINT_SOURCE))
+                .isEqualTo(SpatializerConstants.SOURCE_TYPE_POINT_SOURCE);
+        assertThat(
+                        MediaUtils.convertExtensionsToSourceType(
+                                SpatializerExtensions.SOURCE_TYPE_SOUND_FIELD))
+                .isEqualTo(SpatializerConstants.SOURCE_TYPE_SOUND_FIELD);
+    }
+
+    @Test
+    public void convertExtensionsToSourceType_throwsExceptionForInvalidValue() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> MediaUtils.convertExtensionsToSourceType(100));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MovableComponentImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MovableComponentImplTest.java
new file mode 100644
index 0000000..2cef0e9
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/MovableComponentImplTest.java
@@ -0,0 +1,2480 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Binder;
+import android.os.IBinder;
+import android.view.Display;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.Quatf;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.AnchorPlacement;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.MovableComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.PanelEntity;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.impl.perception.Anchor;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.impl.perception.Plane.PlaneData;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNodeTransform;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeReformEvent;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Expect;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.Objects;
+
+@RunWith(RobolectricTestRunner.class)
+public class MovableComponentImplTest {
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity activity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final EntityManager entityManager = new EntityManager();
+
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    private final ImpSplitEngineRenderer splitEngineRenderer =
+            Mockito.mock(ImpSplitEngineRenderer.class);
+    private JxrPlatformAdapter fakeRuntime;
+    private ActivitySpaceImpl activitySpaceImpl;
+    private Node activitySpaceNode;
+    private final AndroidXrEntity activitySpaceRoot = Mockito.mock(AndroidXrEntity.class);
+    private PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose;
+    private final PanelShadowRenderer panelShadowRenderer = Mockito.mock(PanelShadowRenderer.class);
+
+    @Rule public final Expect expect = Expect.create();
+
+    @Before
+    public void setUp() {
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(mock(Session.class)));
+        fakeRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        entityManager,
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+        activitySpaceImpl = (ActivitySpaceImpl) fakeRuntime.getActivitySpace();
+        activitySpaceNode = activitySpaceImpl.getNode();
+        perceptionSpaceActivityPose =
+                (PerceptionSpaceActivityPoseImpl) fakeRuntime.getPerceptionSpaceActivityPose();
+    }
+
+    private Entity createTestEntity() {
+        return fakeRuntime.createEntity(new Pose(), "test", fakeRuntime.getActivitySpace());
+    }
+
+    private PanelEntity createTestPanelEntity() {
+        Display display = activity.getSystemService(DisplayManager.class).getDisplays()[0];
+        Context displayContext = activity.createDisplayContext(display);
+        View view = new View(displayContext);
+        view.setLayoutParams(new LayoutParams(640, 480));
+        SurfaceControlViewHost surfaceControlViewHost =
+                new SurfaceControlViewHost(
+                        displayContext,
+                        Objects.requireNonNull(displayContext.getDisplay()),
+                        new Binder());
+        surfaceControlViewHost.setView(view, 10, 10);
+        Node node = fakeExtensions.createNode();
+
+        PanelEntityImpl panelEntity =
+                new PanelEntityImpl(
+                        node,
+                        fakeExtensions,
+                        entityManager,
+                        surfaceControlViewHost,
+                        new PixelDimensions(10, 10),
+                        fakeExecutor);
+        panelEntity.setParent(activitySpaceImpl);
+        return panelEntity;
+    }
+
+    private void setActivitySpacePose(Pose pose, float scale) {
+        Matrix4 poseMatrix = Matrix4.fromPose(pose);
+        Matrix4 scaleMatrix = Matrix4.fromScale(scale);
+        Matrix4 scaledPoseMatrix = poseMatrix.times(scaleMatrix);
+        Mat4f mat4f = new Mat4f(scaledPoseMatrix.getData());
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        ((FakeNode) activitySpaceNode).sendTransformEvent(nodeTransformEvent);
+        fakeExecutor.runAll();
+    }
+
+    private ImmutableSet<JxrPlatformAdapter.AnchorPlacement> createAnyAnchorPlacement() {
+        JxrPlatformAdapter.AnchorPlacement anchorPlacement =
+                fakeRuntime.createAnchorPlacementForPlanes(
+                        ImmutableSet.of(PlaneType.ANY), ImmutableSet.of(PlaneSemantic.ANY));
+        return ImmutableSet.of(anchorPlacement);
+    }
+
+    @Test
+    public void addMovableComponent_addsReformOptionsToNode() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ false,
+                        /* scaleInZ= */ false,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(ReformOptions.ALLOW_MOVE);
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+    }
+
+    @Test
+    public void addMovableComponent_addsSystemMovableFlagToNode() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(ReformOptions.ALLOW_MOVE);
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(
+                        ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT
+                                | ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT);
+    }
+
+    @Test
+    public void addMovableComponent_addsScaleInZFlagToNode() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ false,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(ReformOptions.ALLOW_MOVE);
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(
+                        ReformOptions.FLAG_SCALE_WITH_DISTANCE
+                                | ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT);
+    }
+
+    @Test
+    public void addMovableComponent_addsAllFlagsToNode() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(ReformOptions.ALLOW_MOVE);
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(
+                        ReformOptions.FLAG_SCALE_WITH_DISTANCE
+                                | ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT
+                                | ReformOptions.FLAG_POSE_RELATIVE_TO_PARENT);
+    }
+
+    @Test
+    public void setSystemMovableFlag_alsoUpdatesEntityPoseAndScale() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        Pose expectedPose =
+                new Pose(new Vector3(2f, 2f, 2f), new Quaternion(0.5f, 0.5f, 0.5f, 0.5f));
+        Vector3 expectedScale = new Vector3(1.2f, 1.2f, 1.2f);
+
+        entity.setPose(new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f)));
+        entity.setScale(new Vector3(1f, 1f, 1f));
+
+        FakeNode node = (FakeNode) entity.getNode();
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setProposedPosition(new Vec3(2f, 2f, 2f));
+        reformEvent.setProposedOrientation(new Quatf(0.5f, 0.5f, 0.5f, 0.5f));
+        reformEvent.setProposedScale(new Vec3(1.2f, 1.2f, 1.2f));
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+
+        expect.that(entity.getPose()).isEqualTo(expectedPose);
+        expect.that(entity.getScale()).isEqualTo(expectedScale);
+    }
+
+    @Test
+    public void systemMovableFlagNotSet_doesNotUpdateEntityPoseAndScale() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ false,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        Pose expectedPose = new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+        Vector3 expectedScale = new Vector3(1f, 1f, 1f);
+        entity.setPose(new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f)));
+        entity.setScale(new Vector3(1f, 1f, 1f));
+
+        FakeNode node = (FakeNode) entity.getNode();
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setProposedPosition(new Vec3(2f, 2f, 2f));
+        reformEvent.setProposedOrientation(new Quatf(0.5f, 0.5f, 0.5f, 0.5f));
+        reformEvent.setProposedScale(new Vec3(1.2f, 1.2f, 1.2f));
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+
+        expect.that(entity.getPose()).isEqualTo(expectedPose);
+        expect.that(entity.getScale()).isEqualTo(expectedScale);
+    }
+
+    @Test
+    public void setSizeOnMovableComponent_setsSizeOnNodeReformOptions() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        movableComponent.setSize(new Dimensions(2f, 2f, 2f));
+        assertThat(node.getReformOptions().getCurrentSize().x).isEqualTo(2f);
+        assertThat(node.getReformOptions().getCurrentSize().y).isEqualTo(2f);
+        assertThat(node.getReformOptions().getCurrentSize().z).isEqualTo(2f);
+    }
+
+    @Test
+    public void scaleWithDistanceOnMovableComponent_defaultsToDefaultMode() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        // Default value for scaleWithDistanceMode is DEFAULT.
+        assertThat(movableComponent.getScaleWithDistanceMode())
+                .isEqualTo(MovableComponent.ScaleWithDistanceMode.DEFAULT);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        assertThat(node.getReformOptions().getScaleWithDistanceMode())
+                .isEqualTo(ReformOptions.SCALE_WITH_DISTANCE_MODE_DEFAULT);
+    }
+
+    @Test
+    public void setScaleWithDistanceOnMovableComponent_setsScaleWithDistanceOnNodeReformOptions() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+
+        movableComponent.setScaleWithDistanceMode(MovableComponent.ScaleWithDistanceMode.DMM);
+
+        assertThat(movableComponent.getScaleWithDistanceMode())
+                .isEqualTo(MovableComponent.ScaleWithDistanceMode.DMM);
+        assertThat(node.getReformOptions().getScaleWithDistanceMode())
+                .isEqualTo(ReformOptions.SCALE_WITH_DISTANCE_MODE_DMM);
+    }
+
+    @Test
+    public void setPropertiesOnMovableComponentAttachLater_setsPropertiesOnNodeReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        movableComponent.setSize(new Dimensions(2f, 2f, 2f));
+        MoveEventListener mockMoveEventListener = mock(MoveEventListener.class);
+        movableComponent.addMoveEventListener(directExecutor(), mockMoveEventListener);
+        assertThat(movableComponent.reformEventConsumer).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+
+        assertThat(node.getReformOptions().getCurrentSize().x).isEqualTo(2f);
+        assertThat(node.getReformOptions().getCurrentSize().y).isEqualTo(2f);
+        assertThat(node.getReformOptions().getCurrentSize().z).isEqualTo(2f);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+        assertThat(entity.reformEventConsumerMap).isNotEmpty();
+    }
+
+    @Test
+    public void addMoveEventListener_onlyInvokedOnMoveEvent() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        Vector3 initialTranslation = new Vector3(1f, 2f, 3f);
+        Vector3 initialScale = new Vector3(1.1f, 1.1f, 1.1f);
+        entity.setPose(new Pose(initialTranslation, new Quaternion()));
+        entity.setScale(initialScale);
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        MoveEventListener mockMoveEventListener = mock(MoveEventListener.class);
+
+        movableComponent.addMoveEventListener(directExecutor(), mockMoveEventListener);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+        assertThat(entity.reformEventConsumerMap).isNotEmpty();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(mockMoveEventListener, never()).onMoveEvent(any());
+
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        ArgumentCaptor<MoveEvent> moveEventCaptor = ArgumentCaptor.forClass(MoveEvent.class);
+        verify(mockMoveEventListener).onMoveEvent(moveEventCaptor.capture());
+        MoveEvent moveEvent = moveEventCaptor.getValue();
+        assertThat(moveEvent.previousPose.getTranslation()).isEqualTo(initialTranslation);
+        assertThat(moveEvent.previousScale).isEqualTo(initialScale);
+    }
+
+    @Test
+    public void addMoveEventListenerWithExecutor_invokesListenerOnGivenExecutor() {
+        Entity entity = createTestEntity();
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        MoveEventListener mockMoveEventListener = mock(MoveEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, mockMoveEventListener);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+        verify(mockMoveEventListener).onMoveEvent(any());
+    }
+
+    @Test
+    public void addMoveEventListenerMultiple_invokesAllListeners() {
+        Entity entity = createTestEntity();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        MoveEventListener mockMoveEventListener1 = mock(MoveEventListener.class);
+        MoveEventListener mockMoveEventListener2 = mock(MoveEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, mockMoveEventListener1);
+        movableComponent.addMoveEventListener(executorService, mockMoveEventListener2);
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        verify(mockMoveEventListener1).onMoveEvent(any());
+        verify(mockMoveEventListener2).onMoveEvent(any());
+    }
+
+    @Test
+    public void removeMoveEventListenerMultiple_removesGivenListener() {
+        Entity entity = createTestEntity();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        MoveEventListener mockMoveEventListener1 = mock(MoveEventListener.class);
+        MoveEventListener mockMoveEventListener2 = mock(MoveEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, mockMoveEventListener1);
+        movableComponent.addMoveEventListener(executorService, mockMoveEventListener2);
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        fakeExecutor.runAll();
+        executorService.runAll();
+
+        // Verify both listeners are invoked.
+        verify(mockMoveEventListener1).onMoveEvent(any());
+        verify(mockMoveEventListener2).onMoveEvent(any());
+
+        movableComponent.removeMoveEventListener(mockMoveEventListener1);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The first listener, which we removed, should not be invoked again.
+        verify(mockMoveEventListener1).onMoveEvent(any());
+        verify(mockMoveEventListener2, times(2)).onMoveEvent(any());
+    }
+
+    @Test
+    public void removeMovableComponent_clearsMoveReformOptionsAndMoveEventListeners() {
+        Entity entity = createTestEntity();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        MoveEventListener mockMoveEventListener = mock(MoveEventListener.class);
+
+        movableComponent.addMoveEventListener(directExecutor(), mockMoveEventListener);
+        assertThat(movableComponent.reformEventConsumer).isNotNull();
+        assertThat(((AndroidXrEntity) entity).reformEventConsumerMap).isNotEmpty();
+
+        entity.removeComponent(movableComponent);
+        assertThat(node.getReformOptions().getEnabledReform() & ReformOptions.ALLOW_MOVE)
+                .isEqualTo(0);
+        assertThat(
+                        node.getReformOptions().getFlags()
+                                & (ReformOptions.FLAG_SCALE_WITH_DISTANCE
+                                        | ReformOptions.FLAG_ALLOW_SYSTEM_MOVEMENT))
+                .isEqualTo(0);
+        assertThat(movableComponent.reformEventConsumer).isNull();
+        assertThat(((AndroidXrEntity) entity).reformEventConsumerMap).isEmpty();
+    }
+
+    @Test
+    public void movableComponent_canAttachAgainAfterDetach() {
+        Entity entity = createTestEntity();
+        assertThat(entity).isNotNull();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        /* anchorPlacement= */ ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        entity.removeComponent(movableComponent);
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+    }
+
+    @Test
+    public void anchorable_updatesThePoseBasedOnPlanes() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        Entity entity = createTestEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags()).isEqualTo(0);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 3 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units
+        // above it.
+        Pose expectedPosition = new Pose(new Vector3(1f, 3f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+
+        // The panel shadow renderer should have no interaction.
+        verify(panelShadowRenderer, never()).updatePanelPose(any(), any(), any());
+        verify(panelShadowRenderer, never()).destroy();
+        verify(panelShadowRenderer, never()).hidePlane();
+
+        // The pose should have moved since the systemMovable is true.
+        assertPose(entity.getPose(), expectedPosition);
+    }
+
+    @Test
+    public void anchorable_nullParent_updatesThePoseBasedOnPlanes() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        Entity entity = createTestEntity();
+        entity.setParent(null);
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags()).isEqualTo(0);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 3 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units
+        // above it.
+        Pose expectedPosition = new Pose(new Vector3(1f, 3f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+
+        // The pose should have moved since the systemMovable is true.
+        assertPose(entity.getPose(), expectedPosition);
+    }
+
+    @Test
+    public void anchorable_updatesPoseButDoesNotMove_ifNotSystemMovable() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        Entity entity = createTestEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ false,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 3 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units
+        // above it.
+        Pose expectedPosition = new Pose(new Vector3(1f, 3f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+
+        // The panel shadow renderer should have no interaction if it is not system movable.
+        verify(panelShadowRenderer, never()).updatePanelPose(any(), any(), any());
+        verify(panelShadowRenderer, never()).destroy();
+        verify(panelShadowRenderer, never()).hidePlane();
+
+        // The pose should not have moved since the systemMovable is false.
+        assertPose(entity.getPose(), Pose.Identity);
+    }
+
+    @Test
+    public void anchorable_withNonActivityParent_updatesPoseBasedOnPlanesAndParent() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        // Create a parent entity whose pose is below the activity space pose.
+        Entity parentEntity = createTestEntity();
+        parentEntity.setPose(new Pose(new Vector3(0f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)));
+        PanelEntity entity = createTestPanelEntity();
+        entity.setParent(parentEntity);
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 3 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units
+        // above it. Since the parent is 1 unit below the activity space, the expected position
+        // should
+        // be 4 units above the parent.
+        Pose expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+
+        // The pose should have moved since the systemMovable is true.
+        assertPose(entity.getPose(), expectedPosition);
+    }
+
+    @Test
+    public void anchorableAndScaledParent_updatesThePoseBasedOnPlanes() {
+        // Set the activity space pose to be 1 unit to the left of the origin. with a scale of 2.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 2f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane. This needs to be divided by the scale of the activity space.
+        reformEvent.setProposedPosition(new Vec3(.5f, .5f, .5f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 1.5 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units above it and the activity space is scaled by 2.
+        Pose expectedPosition =
+                new Pose(new Vector3(.5f, 1.5f, .5f), new Quaternion(0f, 0f, 0f, 1f));
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+    }
+
+    @Test
+    public void anchorable_withinAnchorDistance_setsAnchorEntity() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+    }
+
+    @Test
+    public void anchorable_withinAnchorDistanceAboveAnchor_resetsPose() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 2 + half the MIN_PLANE_ANCHOR_DISTANCE above the origin. So
+        // it
+        // would be right above the plane.
+        reformEvent.setProposedPosition(
+                new Vec3(1f, 3f + MovableComponentImpl.MIN_PLANE_ANCHOR_DISTANCE / 2f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+    }
+
+    @Test
+    public void anchorable_withIncorrectPlaneType_doesNotCreateAnchor() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        // Set the anchor placement to be a table.
+        AnchorPlacement anchorPlacement =
+                fakeRuntime.createAnchorPlacementForPlanes(
+                        ImmutableSet.of(PlaneType.ANY), ImmutableSet.of(PlaneSemantic.TABLE));
+        ImmutableSet<AnchorPlacement> anchorPlacementSet = ImmutableSet.of(anchorPlacement);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        anchorPlacementSet,
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be 3 unit above the activity in order to rest on the plane.
+        // It
+        // is 3 units because the activity space is 1 unit below of the origin and the plane is 2
+        // units
+        // above it. However, since the plane is not a table plane, the anchor should not be
+        // created.
+        Pose expectedPosition = new Pose(new Vector3(1f, 3f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was not set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isNull();
+    }
+
+    @Test
+    public void anchorable_withinAnchorDistanceAndScale_setsAnchorEntityAndScales() {
+        // Set the activity space pose to be 1 unit to the left of the OpenXR origin and add a scale
+        // of
+        // 2.
+        float activityScale = 2f;
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), activityScale);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        Vector3 entityScale = new Vector3(1f, 3f, 5f);
+        PanelEntity entity = createTestPanelEntity();
+        entity.setScale(entityScale);
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane. This needs to be divided by the scale of the activity space.
+        reformEvent.setProposedPosition(new Vec3(.5f, .5f, .5f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor. The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis. Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+        assertVector3(entity.getWorldSpaceScale(), entityScale.times(activityScale));
+    }
+
+    @Test
+    public void anchorable_noPlanes_keepsProposedPose() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of());
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be unchanged from the proposed event
+        Pose expectedPosition = new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+    }
+
+    @Test
+    public void anchorable_noPlaneData_keepsProposedPose() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        Plane plane = mock(Plane.class);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.getData(any())).thenReturn(null);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be unchanged from the proposed event
+        Pose expectedPosition = new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+    }
+
+    @Test
+    public void anchorable_outsideExtents_keepsProposedPose() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        Plane plane = mock(Plane.class);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(5f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // The expected position should be unchanged from the proposed event
+        Pose expectedPosition = new Pose(new Vector3(1f, 1f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+    }
+
+    @Test
+    public void anchorable_resetsToActivityPoseAfterAnchoring() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+
+        // Put the proposed position at 4 above the origin so it would be off the plane. It should
+        // reset to the activity space pose and rotation.
+        reformEvent.setProposedPosition(new Vec3(1f, 4f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving to (1, 4, 1) relative to the activity space. This should pull the entity away from
+        // the
+        // anchor and it should be reparented to the activity space.
+        expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(2);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertPose(entity.getPose(), expectedPosition);
+        // Check that parent was updated to the activity space.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(activitySpaceImpl);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+    }
+
+    @Test
+    public void anchorable_resetsAndScaleToActivityPoseAfterAnchoring() {
+        // Set the activity space pose to be 1 unit to the left of the OpenXR origin and add a scale
+        // of
+        // 2.
+        float activityScale = 2f;
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), activityScale);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        Vector3 entityScale = new Vector3(1f, 3f, 5f);
+        PanelEntity entity = createTestPanelEntity();
+        entity.setScale(entityScale);
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane. This needs to be divided by the scale of the activity space.
+        reformEvent.setProposedPosition(new Vec3(.5f, .5f, .5f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. Which results in an updated pose of (0, 0, 1)
+        // relative to the anchor. The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis. Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertPose(entity.getPose(), expectedPosition);
+        assertVector3(entity.getScale(), entityScale.times(activityScale));
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+        assertVector3(entity.getWorldSpaceScale(), entityScale.times(activityScale));
+
+        // Put the proposed position at 4 above the activity space so it would be off the plane. It
+        // should reset to the activity space pose and rotation.
+        reformEvent.setProposedPosition(new Vec3(1f, 4f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving to (1, 4, 1) relative to the activity space. This should pull the entity away from
+        // the
+        // anchor and it should be reparented to the activity space.
+        expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(2);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertPose(entity.getPose(), expectedPosition);
+        // Check that parent was updated to the activity space.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(activitySpaceImpl);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+        // Check that the scale was updated to the original scale.
+        assertVector3(entity.getScale(), entityScale);
+    }
+
+    @Test
+    public void anchorableChildOfEntity_resetsToActivityPoseAfterAnchoring() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        // Create a parent entity whose pose is below the activity space pose.
+        Entity parentEntity = createTestEntity();
+        parentEntity.setPose(new Pose(new Vector3(0f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)));
+        PanelEntity entity = createTestPanelEntity();
+        entity.setParent(parentEntity);
+
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1 above the origin. It would need to move up 1 unit to be on
+        // the
+        // plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+
+        // Put the proposed position at 4 above the origin so it would be off the plane. It should
+        // reset to the activity space pose and rotation.
+        reformEvent.setProposedPosition(new Vec3(1f, 4f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving to (1, 4, 1) relative to the activity space. This should pull the entity away from
+        // the
+        // anchor and it should be reparented to the activity space not the original parent..
+        expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(2);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        assertPose(entity.getPose(), expectedPosition);
+        // Check that parent was updated to the activity space.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(activitySpaceImpl);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+    }
+
+    @Test
+    public void anchorable_shouldDispose_disposesAnchorAfterUnparenting() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ true,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+        // Cache the anchor entity.
+        Entity anchorEntity = moveEventListener.lastMoveEvent.updatedParent;
+
+        // Put the proposed position at 4 above the origin so it would be off the plane. It should
+        // reset to the activity space pose and rotation.
+        reformEvent.setProposedPosition(new Vec3(1f, 4f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving to (1, 4, 1) relative to the activity space. This should pull the entity away from
+        // the
+        // anchor and it should be reparented to the activity space.
+        expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(2);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(activitySpaceImpl);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+
+        // Verify that the anchor entity was disposed by checking that it is no longer in the entity
+        // manager.
+        assertThat(entityManager.getEntityForNode(((AndroidXrEntity) anchorEntity).getNode()))
+                .isNull();
+    }
+
+    @Test
+    public void anchorable_shouldDispose_doeNotDisposeIfAnchorHasChildren() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        Anchor anchor = mock(Anchor.class);
+        IBinder sharedAnchorToken = Mockito.mock(IBinder.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+        when(plane.createAnchor(any(), any())).thenReturn(anchor);
+        when(anchor.getAnchorToken()).thenReturn(sharedAnchorToken);
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.VERTICAL.intValue,
+                        Plane.Label.WALL.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ true,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ true,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+        TestMoveEventListener moveEventListener = new TestMoveEventListener();
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        // Add the move event listener and the anchored event listener.
+        movableComponent.addMoveEventListener(executorService, moveEventListener);
+        // The reform options for parenting and moving should not be set when it is anchorable.
+        assertThat(node.getReformOptions().getFlags())
+                .isEqualTo(ReformOptions.FLAG_SCALE_WITH_DISTANCE);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1  above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving from (1, 3, 1) relative to the activity space to be relative to the anchor which
+        // is
+        // (1, 3, 0) relative to the activity space. which results in an updated pose of (0, 0, 1)
+        // relative to the anchor.The (-0.707f, 0f, 0f, 0.707f) Quaternion represents a 90 degree
+        // rotation around the x-axis Which is expected when the panel is rotated into the plane's
+        // reference space.
+        Pose expectedPosition =
+                new Pose(new Vector3(0f, 0f, 1f), new Quaternion(-0.707f, 0f, 0f, 0.707f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(1);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isInstanceOf(AnchorEntity.class);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+        // Cache the anchor entity.
+        Entity anchorEntity = moveEventListener.lastMoveEvent.updatedParent;
+
+        Entity child = createTestEntity();
+        anchorEntity.addChild(child);
+
+        // Put the proposed position at 4 above the origin so it would be off the plane. It should
+        // reset to the activity space pose and rotation.
+        reformEvent.setProposedPosition(new Vec3(1f, 4f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        // Moving to (1, 4, 1) relative to the activity space. This should pull the entity away from
+        // the
+        // anchor and it should be reparented to the activity space.
+        expectedPosition = new Pose(new Vector3(1f, 4f, 1f), new Quaternion(0f, 0f, 0f, 1f));
+
+        assertThat(moveEventListener.callCount).isEqualTo(2);
+        assertPose(moveEventListener.lastMoveEvent.currentPose, expectedPosition);
+        // Check that the anchor entity was set.
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(activitySpaceImpl);
+        assertThat(moveEventListener.lastMoveEvent.updatedParent).isEqualTo(entity.getParent());
+
+        // Verify that the anchor entity wasn't disposed by checking that it is in the entity
+        // manager.
+        assertThat(entityManager.getEntityForNode(((AndroidXrEntity) anchorEntity).getNode()))
+                .isEqualTo(anchorEntity);
+    }
+
+    @Test
+    public void anchorablePanelEntity_nearPlane_rendersShadow() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 1 above the origin. so it would need to move up 1 unit to
+        // be on the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+
+        // Since it is by the plane a call should be made to the panel shadow renderer.
+        verify(panelShadowRenderer)
+                .updatePanelPose(
+                        new Pose(new Vector3(0f, 2f, 1f), new Quaternion(0f, 0f, 0f, 1f)),
+                        RuntimeUtils.fromPerceptionPose(perceptionPose),
+                        (PanelEntityImpl) entity);
+    }
+
+    @Test
+    public void anchorablePanelEntity_awayFromPlane_hidesShadow() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Put the proposed position at 5 above the origin. so it is far away from the plane.
+        reformEvent.setProposedPosition(new Vec3(1f, 5f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_ONGOING);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+
+        // Since it is by the plane a call should be made to the panel shadow renderer.
+        verify(panelShadowRenderer).hidePlane();
+    }
+
+    @Test
+    public void anchorablePanelEntity_endMovement_callsDestroy() {
+        // Set the activity space pose to be 1 unit to the left of the origin.
+        setActivitySpacePose(
+                new Pose(new Vector3(-1f, -1f, 0f), new Quaternion(0f, 0f, 0f, 1f)), 1f);
+        Session session = Mockito.mock(Session.class);
+        Plane plane = mock(Plane.class);
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        when(session.getAllPlanes()).thenReturn(ImmutableList.of(plane));
+
+        // Create a perception plane that is 2 units above the origin.
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(0f, 2f, 0f, 0f, 0f, 0f, 1f);
+        PlaneData planeData =
+                new PlaneData(
+                        perceptionPose,
+                        1f,
+                        1f,
+                        Plane.Type.HORIZONTAL_UPWARD_FACING.intValue,
+                        Plane.Label.FLOOR.intValue);
+        when(plane.getData(any())).thenReturn(planeData);
+
+        PanelEntity entity = createTestPanelEntity();
+        // Set anchorPlacement to any plane.
+        MovableComponent movableComponent =
+                new MovableComponentImpl(
+                        /* systemMovable= */ true,
+                        /* scaleInZ= */ false,
+                        createAnyAnchorPlacement(),
+                        /* shouldDisposeParentAnchor= */ false,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        assertThat(movableComponent).isNotNull();
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        FakeNode node = (FakeNode) ((AndroidXrEntity) entity).getNode();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+
+        // Set the reform state to end so that the plane shadow gets destroyed.
+        reformEvent.setProposedPosition(new Vec3(1f, 1f, 1f));
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+
+        // Since it is by the plane a call should be made to the panel shadow renderer.
+        verify(panelShadowRenderer).destroy();
+    }
+
+    static class TestMoveEventListener implements MoveEventListener {
+        int callCount = 0;
+        MoveEvent lastMoveEvent = null;
+
+        @Override
+        public void onMoveEvent(MoveEvent event) {
+            lastMoveEvent = event;
+            callCount++;
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/OpenXrActivityPoseTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/OpenXrActivityPoseTest.java
new file mode 100644
index 0000000..dd24f63
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/OpenXrActivityPoseTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.CameraViewActivityPose;
+import androidx.xr.scenecore.common.BaseActivityPose;
+import androidx.xr.scenecore.impl.perception.Fov;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.impl.perception.ViewProjection;
+import androidx.xr.scenecore.impl.perception.ViewProjections;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeGltfModelToken;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+import java.util.Arrays;
+import java.util.List;
+
+/** Test for common behaviour for ActivityPoses whose world position is retrieved from OpenXr. */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class OpenXrActivityPoseTest {
+    private final AndroidXrEntity activitySpaceRoot = mock(AndroidXrEntity.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final Session session = mock(Session.class);
+    private final FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+    private final EntityManager entityManager = new EntityManager();
+    private final ActivitySpaceImpl activitySpace =
+            new ActivitySpaceImpl(
+                    fakeExtensions.createNode(),
+                    fakeExtensions,
+                    entityManager,
+                    () -> fakeExtensions.fakeSpatialState,
+                    executor);
+
+    enum OpenXrActivityPoseType {
+        HEAD_ACTIVITY_POSE,
+        CAMERA_ACTIVITY_POSE,
+    }
+
+    @Parameter(0)
+    public OpenXrActivityPoseType testActivityPoseType;
+
+    BaseActivityPose testActivityPose;
+
+    @Parameters
+    public static List<Object> data() throws Exception {
+        return Arrays.asList(
+                // TODO: b/377812131 - Add OpenXrActivityPoseType.CAMERA_ACTIVITY_POSE.
+                new Object[] {OpenXrActivityPoseType.HEAD_ACTIVITY_POSE});
+    }
+
+    @Before
+    public void doBeforeEachTest() {
+        // By default, set the activity space to the root of the underlying OpenXR reference space.
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+    }
+
+    /** Creates a HeadActivityPoseImpl instance. */
+    private HeadActivityPoseImpl createHeadActivityPose(
+            ActivitySpaceImpl activitySpace, AndroidXrEntity activitySpaceRoot) {
+        return new HeadActivityPoseImpl(activitySpace, activitySpaceRoot, perceptionLibrary);
+    }
+
+    /** Creates a CameraViewActivityPoseImpl instance. */
+    private CameraViewActivityPoseImpl createCameraViewActivityPose(
+            ActivitySpaceImpl activitySpace, AndroidXrEntity activitySpaceRoot) {
+        return new CameraViewActivityPoseImpl(
+                CameraViewActivityPose.CAMERA_TYPE_LEFT_EYE,
+                activitySpace,
+                activitySpaceRoot,
+                perceptionLibrary);
+    }
+
+    private BaseActivityPose createTestActivityPose() {
+        return createTestActivityPose(activitySpace, activitySpaceRoot);
+    }
+
+    private BaseActivityPose createTestActivityPose(
+            ActivitySpaceImpl activitySpace, AndroidXrEntity activitySpaceRoot) {
+        switch (testActivityPoseType) {
+            case HEAD_ACTIVITY_POSE:
+                return createHeadActivityPose(activitySpace, activitySpaceRoot);
+            case CAMERA_ACTIVITY_POSE:
+                return createCameraViewActivityPose(activitySpace, activitySpaceRoot);
+        }
+        return null;
+    }
+
+    private void setPerceptionPose(Pose pose) {
+        when(perceptionLibrary.getSession()).thenReturn(session);
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                pose == null ? null : RuntimeUtils.poseToPerceptionPose(pose);
+        switch (testActivityPoseType) {
+            case HEAD_ACTIVITY_POSE:
+                {
+                    when(session.getHeadPose()).thenReturn(perceptionPose);
+                    break;
+                }
+            case CAMERA_ACTIVITY_POSE:
+                {
+                    ViewProjection viewProjection =
+                            new ViewProjection(perceptionPose, new Fov(0, 0, 0, 0));
+                    when(session.getStereoViews())
+                            .thenReturn(new ViewProjections(viewProjection, viewProjection));
+                    break;
+                }
+        }
+    }
+
+    /** Creates a generic glTF entity. */
+    private GltfEntityImpl createGltfEntity() {
+        FakeGltfModelToken modelToken = new FakeGltfModelToken("model");
+        GltfModelResourceImpl model = new GltfModelResourceImpl(modelToken);
+        return new GltfEntityImpl(model, activitySpace, fakeExtensions, entityManager, executor);
+    }
+
+    @Test
+    public void
+            getPoseInActivitySpace_noActivitySpaceOpenXrReferenceSpacePose_returnsIdentityPose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        setPerceptionPose(pose);
+        activitySpace.openXrReferenceSpacePose = null;
+
+        assertPose(testActivityPose.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_whenAtSamePose_returnsIdentityPose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        setPerceptionPose(pose);
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromPose(pose));
+
+        assertPose(testActivityPose.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getPoseInActivitySpace_returnsDifferencePose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        setPerceptionPose(pose);
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+
+        assertPose(testActivityPose.getPoseInActivitySpace(), pose);
+    }
+
+    @Test
+    public void getActivitySpaceScale_returnsInverseOfActivitySpaceWorldScale() throws Exception {
+        float activitySpaceScale = 5f;
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromScale(activitySpaceScale));
+        testActivityPose = createTestActivityPose();
+        assertVector3(
+                testActivityPose.getActivitySpaceScale(),
+                new Vector3(1f, 1f, 1f).div(activitySpaceScale));
+    }
+
+    @Test
+    public void getPoseInActivitySpace_withNoActivitySpace_returnsIdentityPose() {
+        testActivityPose = createTestActivityPose(/* activitySpace= */ null, activitySpaceRoot);
+
+        assertPose(testActivityPose.getPoseInActivitySpace(), new Pose());
+    }
+
+    @Test
+    public void getActivitySpacePose_whenAtSamePose_returnsIdentityPose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1).toNormalized());
+        setPerceptionPose(pose);
+        when(activitySpaceRoot.getPoseInActivitySpace()).thenReturn(pose);
+
+        assertPose(testActivityPose.getActivitySpacePose(), new Pose());
+    }
+
+    @Test
+    public void getActivitySpacePose_returnsDifferencePose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        setPerceptionPose(pose);
+        when(activitySpaceRoot.getPoseInActivitySpace()).thenReturn(new Pose());
+
+        assertPose(testActivityPose.getActivitySpacePose(), pose);
+    }
+
+    @Test
+    public void getActivitySpacePoseWithError_returnsLastKnownPose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1, 1, 1), new Quaternion(0, 1, 0, 1));
+        setPerceptionPose(pose);
+        when(activitySpaceRoot.getPoseInActivitySpace()).thenReturn(new Pose());
+        assertPose(testActivityPose.getActivitySpacePose(), pose);
+
+        setPerceptionPose(null);
+        assertPose(testActivityPose.getActivitySpacePose(), pose);
+    }
+
+    @Test
+    public void getActivitySpacePose_withNonAndroidXrActivitySpaceRoot_returnsIdentityPose()
+            throws Exception {
+        testActivityPose = createTestActivityPose(activitySpace, /* activitySpaceRoot= */ null);
+
+        assertPose(testActivityPose.getActivitySpacePose(), new Pose());
+    }
+
+    @Test
+    public void transformPoseTo_withActivitySpace_returnsTransformedPose() {
+        testActivityPose = createTestActivityPose();
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+        setPerceptionPose(pose);
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+
+        Pose userHeadSpaceOffset =
+                new Pose(
+                        new Vector3(10f, 0f, 0f),
+                        Quaternion.fromEulerAngles(new Vector3(0f, 0f, 90f)));
+        Pose transformedPose = testActivityPose.transformPoseTo(userHeadSpaceOffset, activitySpace);
+        assertPose(
+                transformedPose,
+                new Pose(
+                        new Vector3(11f, 2f, 3f),
+                        Quaternion.fromEulerAngles(new Vector3(0f, 0f, 90f))));
+    }
+
+    @Test
+    public void transformPoseTo_fromActivitySpaceChild_returnsUserHeadSpacePose() {
+        testActivityPose = createTestActivityPose();
+        GltfEntityImpl childEntity1 = createGltfEntity();
+        Pose pose = new Pose(new Vector3(1f, 2f, 3f), Quaternion.Identity);
+        Pose childPose = new Pose(new Vector3(-1f, -2f, -3f), Quaternion.Identity);
+
+        activitySpace.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        activitySpace.addChild(childEntity1);
+        childEntity1.setPose(childPose);
+        setPerceptionPose(pose);
+
+        assertPose(
+                activitySpace.transformPoseTo(new Pose(), testActivityPose),
+                new Pose(new Vector3(-1f, -2f, -3f), Quaternion.Identity));
+
+        Pose transformedPose = childEntity1.transformPoseTo(new Pose(), testActivityPose);
+        assertPose(transformedPose, new Pose(new Vector3(-2f, -4f, -6f), Quaternion.Identity));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PanelEntityImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PanelEntityImplTest.java
new file mode 100644
index 0000000..feeedf5
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PanelEntityImplTest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Binder;
+import android.view.Display;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.PixelDimensions;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.Objects;
+
+@RunWith(RobolectricTestRunner.class)
+public class PanelEntityImplTest {
+    private static final Dimensions kVgaResolutionPx = new Dimensions(640f, 480f, 0f);
+    private static final Dimensions kHdResolutionPx = new Dimensions(1280f, 720f, 0f);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity activity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final EntityManager entityManager = new EntityManager();
+    private JxrPlatformAdapterAxr testRuntime;
+
+    SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    ImpSplitEngineRenderer splitEngineRenderer = Mockito.mock(ImpSplitEngineRenderer.class);
+
+    @Before
+    public void setUp() {
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(Mockito.mock(Session.class)));
+
+        testRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApi,
+                        new EntityManager(),
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+    }
+
+    private PanelEntityImpl createPanelEntity(Dimensions surfaceDimensionsPx) {
+        Display display = activity.getSystemService(DisplayManager.class).getDisplays()[0];
+        Context displayContext = activity.createDisplayContext(display);
+        View view = new View(displayContext);
+        view.setLayoutParams(new LayoutParams(640, 480));
+        SurfaceControlViewHost surfaceControlViewHost =
+                new SurfaceControlViewHost(
+                        displayContext,
+                        Objects.requireNonNull(displayContext.getDisplay()),
+                        new Binder());
+        surfaceControlViewHost.setView(
+                view, (int) surfaceDimensionsPx.width, (int) surfaceDimensionsPx.height);
+        Node node = fakeExtensions.createNode();
+
+        PanelEntityImpl panelEntity =
+                new PanelEntityImpl(
+                        node,
+                        fakeExtensions,
+                        entityManager,
+                        surfaceControlViewHost,
+                        new PixelDimensions(
+                                (int) surfaceDimensionsPx.width, (int) surfaceDimensionsPx.height),
+                        fakeExecutor);
+
+        // TODO(b/352829122): introduce a TestRootEntity which can serve as a parent
+        panelEntity.setParent(testRuntime.getActivitySpaceRootImpl());
+        return panelEntity;
+    }
+
+    @Test
+    public void getSizeForPanelEntity_returnsSizeInMeters() {
+        PanelEntityImpl panelEntity = createPanelEntity(kVgaResolutionPx);
+
+        // The (FakeXrExtensions) test default pixel density is 1 pixel per meter.
+        assertThat(panelEntity.getSize().width).isEqualTo(640f);
+        assertThat(panelEntity.getSize().height).isEqualTo(480f);
+        assertThat(panelEntity.getSize().depth).isEqualTo(0f);
+    }
+
+    @Test
+    public void setSizeForPanelEntity_setsSize() {
+        PanelEntityImpl panelEntity = createPanelEntity(kHdResolutionPx);
+
+        // The (FakeXrExtensions) test default pixel density is 1 pixel per meter.
+        assertThat(panelEntity.getSize().width).isEqualTo(1280f);
+        assertThat(panelEntity.getSize().height).isEqualTo(720f);
+        assertThat(panelEntity.getSize().depth).isEqualTo(0f);
+
+        panelEntity.setSize(kVgaResolutionPx);
+
+        assertThat(panelEntity.getSize().width).isEqualTo(640f);
+        assertThat(panelEntity.getSize().height).isEqualTo(480f);
+        assertThat(panelEntity.getSize().depth).isEqualTo(0f);
+    }
+
+    @Test
+    public void setSizeForPanelEntity_updatesPixelDimensions() {
+        PanelEntityImpl panelEntity = createPanelEntity(kHdResolutionPx);
+
+        // The (FakeXrExtensions) test default pixel density is 1 pixel per meter.
+        assertThat(panelEntity.getSize().width).isEqualTo(1280f);
+        assertThat(panelEntity.getSize().height).isEqualTo(720f);
+        assertThat(panelEntity.getSize().depth).isEqualTo(0f);
+
+        panelEntity.setSize(kVgaResolutionPx);
+
+        assertThat(panelEntity.getSize().width).isEqualTo(640f);
+        assertThat(panelEntity.getSize().height).isEqualTo(480f);
+        assertThat(panelEntity.getSize().depth).isEqualTo(0f);
+
+        assertThat(panelEntity.getPixelDimensions().width).isEqualTo(640);
+        assertThat(panelEntity.getPixelDimensions().height).isEqualTo(480);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImplTest.java
new file mode 100644
index 0000000..45c4085
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PerceptionSpaceActivityPoseImplTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeGltfModelToken;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNodeTransform;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class PerceptionSpaceActivityPoseImplTest {
+
+    private final AndroidXrEntity activitySpaceRoot = Mockito.mock(AndroidXrEntity.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+    private final EntityManager entityManager = new EntityManager();
+    private final ActivitySpaceImpl activitySpace =
+            new ActivitySpaceImpl(
+                    fakeExtensions.createNode(),
+                    fakeExtensions,
+                    entityManager,
+                    () -> fakeExtensions.fakeSpatialState,
+                    executor);
+
+    private PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose;
+
+    private FakeNode getActivitySpaceNode() {
+        return (FakeNode) activitySpace.getNode();
+    }
+
+    /** Creates a generic glTF entity. */
+    private GltfEntityImpl createGltfEntity() {
+        FakeGltfModelToken modelToken = new FakeGltfModelToken("model");
+        GltfModelResourceImpl model = new GltfModelResourceImpl(modelToken);
+        return new GltfEntityImpl(model, activitySpace, fakeExtensions, entityManager, executor);
+    }
+
+    @Before
+    public void setUp() {
+        perceptionSpaceActivityPose =
+                new PerceptionSpaceActivityPoseImpl(activitySpace, activitySpaceRoot);
+    }
+
+    @Test
+    public void getPoseInActivitySpace_returnsInverseOfActivitySpacePose() {
+        Matrix4 activitySpaceMatrix =
+                Matrix4.fromTrs(
+                        new Vector3(1.0f, 2.0f, 3.0f),
+                        Quaternion.fromEulerAngles(new Vector3(0f, 0f, 90f)),
+                        new Vector3(1.0f, 1.0f, 1.0f));
+        getActivitySpaceNode()
+                .sendTransformEvent(
+                        new FakeNodeTransform(new Mat4f(activitySpaceMatrix.getData())));
+        executor.runAll();
+
+        Pose poseInActivitySpace = perceptionSpaceActivityPose.getPoseInActivitySpace();
+
+        Pose expectedPose = activitySpaceMatrix.getInverse().getPose();
+        assertPose(poseInActivitySpace, expectedPose);
+    }
+
+    @Test
+    public void transformPoseTo_returnsCorrectPose() {
+        Matrix4 activitySpaceMatrix =
+                Matrix4.fromTrs(
+                        new Vector3(4.0f, 5.0f, 6.0f),
+                        Quaternion.fromEulerAngles(new Vector3(90f, 0f, 0f)),
+                        new Vector3(1.0f, 1.0f, 1.0f));
+        getActivitySpaceNode()
+                .sendTransformEvent(
+                        new FakeNodeTransform(new Mat4f(activitySpaceMatrix.getData())));
+        executor.runAll();
+
+        Pose transformedPose =
+                perceptionSpaceActivityPose.transformPoseTo(new Pose(), activitySpace);
+
+        Pose expectedPose = activitySpaceMatrix.getInverse().getPose();
+        assertPose(transformedPose, expectedPose);
+    }
+
+    @Test
+    public void transformPoseTo_toScaledEntity_returnsCorrectPose() {
+        Matrix4 activitySpaceMatrix =
+                Matrix4.fromTrs(
+                        new Vector3(4.0f, 5.0f, 6.0f),
+                        Quaternion.fromEulerAngles(new Vector3(90f, 0f, 0f)).toNormalized(),
+                        new Vector3(1.0f, 1.0f, 1.0f));
+        getActivitySpaceNode()
+                .sendTransformEvent(
+                        new FakeNodeTransform(new Mat4f(activitySpaceMatrix.getData())));
+        executor.runAll();
+        GltfEntityImpl gltfEntity = createGltfEntity();
+        gltfEntity.setScale(new Vector3(2.0f, 2.0f, 2.0f));
+
+        Pose transformedPose = perceptionSpaceActivityPose.transformPoseTo(new Pose(), gltfEntity);
+
+        Pose unscaledPose = activitySpaceMatrix.getInverse().getPose();
+        Pose expectedPose =
+                new Pose(
+                        unscaledPose.getTranslation().times(new Vector3(0.5f, 0.5f, 0.5f)),
+                        unscaledPose.getRotation());
+        assertPose(transformedPose, expectedPose);
+    }
+
+    @Test
+    public void getActivitySpaceScale_returnsInverseOfActivitySpaceWorldScale() throws Exception {
+        float activitySpaceScale = 5f;
+        this.activitySpace.setOpenXrReferenceSpacePose(Matrix4.fromScale(activitySpaceScale));
+        assertVector3(
+                perceptionSpaceActivityPose.getActivitySpaceScale(),
+                new Vector3(1f, 1f, 1f).div(activitySpaceScale));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PlaneUtilsTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PlaneUtilsTest.java
new file mode 100644
index 0000000..264fb3b
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PlaneUtilsTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertRotation;
+
+import androidx.xr.runtime.math.Quaternion;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class PlaneUtilsTest {
+
+    @Test
+    public void rotateEntityToPlane_rotatesToPlan() {
+        // Moving from (0f, 0f, 0f, 1f) int the common space to (0f, 0f, 0f, 1f) in the plane
+        // rotation
+        // results in an updated rotation of (-0.707f, 0f, 0f, 0.707f). This quaternion represents a
+        // 90
+        // degree rotation around the x-axis Which is expected when the panel is rotated into the
+        // plane's reference space.
+        Quaternion planeRotation = new Quaternion(0f, 0f, 0f, 1f);
+        Quaternion proposedRotation = new Quaternion(0f, 0f, 0f, 1f);
+
+        Quaternion updatedRotation =
+                PlaneUtils.rotateEntityToPlane(proposedRotation, planeRotation);
+
+        Quaternion expectedRotation = new Quaternion(-0.707f, 0f, 0f, 0.707f);
+        assertRotation(updatedRotation, expectedRotation);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PointerCaptureComponentImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PointerCaptureComponentImplTest.java
new file mode 100644
index 0000000..11c5ab3
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/PointerCaptureComponentImplTest.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import androidx.xr.extensions.node.InputEvent;
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.Vec3;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent;
+import androidx.xr.scenecore.JxrPlatformAdapter.PointerCaptureComponent.StateListener;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeInputEvent;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class PointerCaptureComponentImplTest {
+
+    // Static private implementation of fakes so that the last received state can be grabbed.
+    private static class FakeStateListener implements StateListener {
+        public int lastState = -1;
+
+        @Override
+        public void onStateChanged(int newState) {
+            lastState = newState;
+        }
+    }
+
+    private static class FakeInputEventListener implements InputEventListener {
+        public JxrPlatformAdapter.InputEvent lastEvent = null;
+
+        @Override
+        public void onInputEvent(JxrPlatformAdapter.InputEvent event) {
+            lastEvent = event;
+        }
+    }
+
+    private final FakeStateListener stateListener = new FakeStateListener();
+
+    private final FakeInputEventListener inputListener = new FakeInputEventListener();
+
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeScheduledExecutorService fakeScheduler = new FakeScheduledExecutorService();
+    private final FakeNode fakeNode = (FakeNode) fakeExtensions.createNode();
+
+    private final Entity entity =
+            new AndroidXrEntity(fakeNode, fakeExtensions, new EntityManager(), fakeScheduler) {};
+
+    @Test
+    public void onAttach_enablesPointerCapture() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+
+        assertThat(component.onAttach(entity)).isTrue();
+
+        assertThat(fakeNode.getPointerCaptureStateCallback()).isNotNull();
+    }
+
+    @Test
+    public void onAttach_setsUpInputEventPropagation() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        FakeInputEvent fakeInput = new FakeInputEvent();
+        fakeInput.setDispatchFlags(InputEvent.DISPATCH_FLAG_CAPTURED_POINTER);
+        fakeInput.setOrigin(new Vec3(0, 0, 0));
+        fakeInput.setDirection(new Vec3(1, 1, 1));
+        fakeNode.sendInputEvent(fakeInput);
+        fakeScheduler.runAll();
+
+        assertThat(inputListener.lastEvent).isNotNull();
+    }
+
+    // This should really be a test on AndroidXrEntity, but that does not have tests so it is here
+    // for
+    // the meantime.
+    @Test
+    public void onAttach_onlyPropagatesCapturedEvents() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        FakeInputEvent fakeCapturedInput = new FakeInputEvent();
+        fakeCapturedInput.setDispatchFlags(InputEvent.DISPATCH_FLAG_CAPTURED_POINTER);
+        fakeCapturedInput.setTimestamp(100);
+        fakeCapturedInput.setOrigin(new Vec3(0, 0, 0));
+        fakeCapturedInput.setDirection(new Vec3(1, 1, 1));
+
+        FakeInputEvent fakeInput = new FakeInputEvent();
+        fakeInput.setTimestamp(200);
+        fakeInput.setOrigin(new Vec3(0, 0, 0));
+        fakeInput.setDirection(new Vec3(1, 1, 1));
+
+        fakeNode.sendInputEvent(fakeCapturedInput);
+        fakeNode.sendInputEvent(fakeInput);
+
+        fakeScheduler.runAll();
+
+        assertThat(inputListener.lastEvent).isNotNull();
+        assertThat(inputListener.lastEvent.timestamp).isEqualTo(fakeCapturedInput.getTimestamp());
+    }
+
+    @Test
+    public void onAttach_propagatesInputOnCorrectThread() {
+        FakeScheduledExecutorService propagationExecutor = new FakeScheduledExecutorService();
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(propagationExecutor, stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        FakeInputEvent fakeCapturedInput = new FakeInputEvent();
+        fakeCapturedInput.setDispatchFlags(InputEvent.DISPATCH_FLAG_CAPTURED_POINTER);
+        fakeCapturedInput.setTimestamp(100);
+        fakeCapturedInput.setOrigin(new Vec3(0, 0, 0));
+        fakeCapturedInput.setDirection(new Vec3(1, 1, 1));
+
+        fakeNode.sendInputEvent(fakeCapturedInput);
+
+        assertThat(propagationExecutor.hasNext()).isFalse();
+        // Run the scheduler associated with the Entity so that the component's executor has the
+        // task
+        // scheduled on it.
+        fakeScheduler.runAll();
+
+        assertThat(propagationExecutor.hasNext()).isTrue();
+    }
+
+    @Test
+    public void onAttach_setsUpCorrectStatePropagation() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        fakeNode.getPointerCaptureStateCallback().accept(Node.POINTER_CAPTURE_STATE_PAUSED);
+        assertThat(stateListener.lastState)
+                .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_PAUSED);
+
+        fakeNode.getPointerCaptureStateCallback().accept(Node.POINTER_CAPTURE_STATE_ACTIVE);
+        assertThat(stateListener.lastState)
+                .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_ACTIVE);
+
+        fakeNode.getPointerCaptureStateCallback().accept(Node.POINTER_CAPTURE_STATE_STOPPED);
+        assertThat(stateListener.lastState)
+                .isEqualTo(PointerCaptureComponent.POINTER_CAPTURE_STATE_STOPPED);
+    }
+
+    @Test
+    public void onAttach_failsIfAlreadyAttached() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+        assertThat(component.onAttach(entity)).isFalse();
+    }
+
+    @Test
+    public void onAttach_failesIfEntityAlreadyHasAnAttachedPointerCaptureComponent() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        PointerCaptureComponentImpl component2 =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component2.onAttach(entity)).isFalse();
+    }
+
+    @Test
+    public void onDetach_stopsPointerCapture() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        component.onDetach(entity);
+
+        assertThat(fakeNode.getPointerCaptureStateCallback()).isNull();
+    }
+
+    @Test
+    public void onDetach_removesInputListener() {
+        PointerCaptureComponentImpl component =
+                new PointerCaptureComponentImpl(directExecutor(), stateListener, inputListener);
+        assertThat(component.onAttach(entity)).isTrue();
+
+        component.onDetach(entity);
+
+        assertThat(fakeNode.getListener()).isNull();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ResizableComponentImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ResizableComponentImplTest.java
new file mode 100644
index 0000000..25ec51e
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/ResizableComponentImplTest.java
@@ -0,0 +1,646 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+
+import androidx.xr.extensions.node.Node;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.extensions.node.ReformOptions;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.Dimensions;
+import androidx.xr.scenecore.JxrPlatformAdapter.Entity;
+import androidx.xr.scenecore.JxrPlatformAdapter.MoveEventListener;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEventListener;
+import androidx.xr.scenecore.impl.perception.PerceptionLibrary;
+import androidx.xr.scenecore.impl.perception.Session;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeReformEvent;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.ar.imp.view.splitengine.ImpSplitEngineRenderer;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+@RunWith(RobolectricTestRunner.class)
+public class ResizableComponentImplTest {
+    private static final Dimensions kMinDimensions = new Dimensions(0f, 0f, 0f);
+    private static final Dimensions kMaxDimensions = new Dimensions(10f, 10f, 10f);
+    private final ActivityController<Activity> activityController =
+            Robolectric.buildActivity(Activity.class);
+    private final Activity activity = activityController.create().start().get();
+    private final FakeScheduledExecutorService fakeExecutor = new FakeScheduledExecutorService();
+    private final PerceptionLibrary perceptionLibrary = mock(PerceptionLibrary.class);
+    private final FakeXrExtensions fakeExtensions = new FakeXrExtensions();
+    private final FakeImpressApi fakeImpressApiImpl = new FakeImpressApi();
+    private final EntityManager entityManager = new EntityManager();
+    private final Node activitySpaceNode = fakeExtensions.createNode();
+    private final ActivitySpaceImpl activitySpaceImpl =
+            new ActivitySpaceImpl(
+                    activitySpaceNode,
+                    fakeExtensions,
+                    entityManager,
+                    () -> fakeExtensions.fakeSpatialState,
+                    fakeExecutor);
+    private final AndroidXrEntity activitySpaceRoot = Mockito.mock(AndroidXrEntity.class);
+    private final PerceptionSpaceActivityPoseImpl perceptionSpaceActivityPose =
+            new PerceptionSpaceActivityPoseImpl(activitySpaceImpl, activitySpaceRoot);
+    private final PanelShadowRenderer panelShadowRenderer = Mockito.mock(PanelShadowRenderer.class);
+
+    private final SplitEngineSubspaceManager splitEngineSubspaceManager =
+            Mockito.mock(SplitEngineSubspaceManager.class);
+    private final ImpSplitEngineRenderer splitEngineRenderer =
+            Mockito.mock(ImpSplitEngineRenderer.class);
+
+    private Entity createTestEntity() {
+        when(perceptionLibrary.initSession(eq(activity), anyInt(), eq(fakeExecutor)))
+                .thenReturn(immediateFuture(mock(Session.class)));
+        JxrPlatformAdapter fakeRuntime =
+                JxrPlatformAdapterAxr.create(
+                        activity,
+                        fakeExecutor,
+                        fakeExtensions,
+                        fakeImpressApiImpl,
+                        entityManager,
+                        perceptionLibrary,
+                        splitEngineSubspaceManager,
+                        splitEngineRenderer,
+                        /* useSplitEngine= */ false);
+        return fakeRuntime.createEntity(new Pose(), "test", fakeRuntime.getActivitySpace());
+    }
+
+    @Test
+    public void addResizableComponentToTwoEntity_fails() {
+        Entity entity1 = createTestEntity();
+        Entity entity2 = createTestEntity();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity1.addComponent(resizableComponent)).isTrue();
+        assertThat(entity2.addComponent(resizableComponent)).isFalse();
+    }
+
+    @Test
+    public void addResizableComponent_addsReformOptionsToNode() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_RESIZE);
+        assertThat(node.getReformOptions().getMinimumSize().x).isEqualTo(kMinDimensions.width);
+        assertThat(node.getReformOptions().getMinimumSize().y).isEqualTo(kMinDimensions.height);
+        assertThat(node.getReformOptions().getMinimumSize().z).isEqualTo(kMinDimensions.depth);
+        assertThat(node.getReformOptions().getMaximumSize().x).isEqualTo(kMaxDimensions.width);
+        assertThat(node.getReformOptions().getMaximumSize().y).isEqualTo(kMaxDimensions.height);
+        assertThat(node.getReformOptions().getMaximumSize().z).isEqualTo(kMaxDimensions.depth);
+    }
+
+    @Test
+    public void setSizeOnResizableComponent_setsSizeOnNodeReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+
+        resizableComponent.setSize(kMaxDimensions);
+        assertThat(node.getReformOptions().getCurrentSize().x).isEqualTo(kMaxDimensions.width);
+        assertThat(node.getReformOptions().getCurrentSize().y).isEqualTo(kMaxDimensions.height);
+        assertThat(node.getReformOptions().getCurrentSize().z).isEqualTo(kMaxDimensions.depth);
+    }
+
+    @Test
+    public void setMinimumSizeOnResizableComponent_setsMinimumSizeOnNodeReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+
+        resizableComponent.setMinimumSize(kMaxDimensions);
+        assertThat(node.getReformOptions().getMinimumSize().x).isEqualTo(kMaxDimensions.width);
+        assertThat(node.getReformOptions().getMinimumSize().y).isEqualTo(kMaxDimensions.height);
+        assertThat(node.getReformOptions().getMinimumSize().z).isEqualTo(kMaxDimensions.depth);
+    }
+
+    @Test
+    public void setMaximumSizeOnResizableComponent_setsMaximumSizeOnNodeReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+
+        resizableComponent.setMaximumSize(kMinDimensions);
+        assertThat(node.getReformOptions().getMaximumSize().x).isEqualTo(kMinDimensions.width);
+        assertThat(node.getReformOptions().getMaximumSize().y).isEqualTo(kMinDimensions.height);
+        assertThat(node.getReformOptions().getMaximumSize().z).isEqualTo(kMinDimensions.depth);
+    }
+
+    @Test
+    public void setFixedAspectRatioOnResizableComponent_setsFixedAspectRatioOnNodeReformOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+
+        resizableComponent.setFixedAspectRatio(2.0f);
+        assertThat(node.getReformOptions().getFixedAspectRatio()).isEqualTo(2.0f);
+        resizableComponent.setFixedAspectRatio(0.0f);
+        assertThat(node.getReformOptions().getFixedAspectRatio()).isEqualTo(0.0f);
+        resizableComponent.setFixedAspectRatio(-1.0f);
+        assertThat(node.getReformOptions().getFixedAspectRatio()).isEqualTo(-1.0f);
+    }
+
+    @Test
+    public void addResizableComponentLater_addsReformOptionsToNode() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        Dimensions testSize = new Dimensions(1f, 1f, 1f);
+        Dimensions testMinSize = new Dimensions(0.25f, 0.25f, 0.25f);
+        Dimensions testMaxSize = new Dimensions(5f, 5f, 5f);
+        resizableComponent.setSize(testSize);
+        resizableComponent.setMinimumSize(testMinSize);
+        resizableComponent.setMaximumSize(testMaxSize);
+
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_RESIZE);
+        assertThat(node.getReformOptions().getCurrentSize().x).isEqualTo(testSize.width);
+        assertThat(node.getReformOptions().getCurrentSize().y).isEqualTo(testSize.height);
+        assertThat(node.getReformOptions().getCurrentSize().z).isEqualTo(testSize.depth);
+        assertThat(node.getReformOptions().getMinimumSize().x).isEqualTo(testMinSize.width);
+        assertThat(node.getReformOptions().getMinimumSize().y).isEqualTo(testMinSize.height);
+        assertThat(node.getReformOptions().getMinimumSize().z).isEqualTo(testMinSize.depth);
+        assertThat(node.getReformOptions().getMaximumSize().x).isEqualTo(testMaxSize.width);
+        assertThat(node.getReformOptions().getMaximumSize().y).isEqualTo(testMaxSize.height);
+        assertThat(node.getReformOptions().getMaximumSize().z).isEqualTo(testMaxSize.depth);
+    }
+
+    @Test
+    public void addResizeEventListener_onlyInvokedOnResizeEvent() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener = mock(ResizeEventListener.class);
+
+        resizableComponent.addResizeEventListener(directExecutor(), mockResizeEventListener);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+        assertThat(entity.reformEventConsumerMap).isNotEmpty();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(mockResizeEventListener, never()).onResizeEvent(any());
+
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(mockResizeEventListener).onResizeEvent(any());
+    }
+
+    @Test
+    public void addResizeEventListenerWithExecutor_invokesListenerOnGivenExecutor() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener = mock(ResizeEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+        verify(mockResizeEventListener).onResizeEvent(any());
+    }
+
+    @Test
+    public void addResizeEventListenerMultiple_invokesAllListeners() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener1 = mock(ResizeEventListener.class);
+        ResizeEventListener mockResizeEventListener2 = mock(ResizeEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener1);
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener2);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+        verify(mockResizeEventListener1).onResizeEvent(any());
+        verify(mockResizeEventListener2).onResizeEvent(any());
+    }
+
+    @Test
+    public void removeResizeEventListenerMultiple_removesGivenListener() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener1 = mock(ResizeEventListener.class);
+        ResizeEventListener mockResizeEventListener2 = mock(ResizeEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener1);
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener2);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+
+        resizableComponent.removeResizeEventListener(mockResizeEventListener1);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+        verify(mockResizeEventListener1).onResizeEvent(any());
+        verify(mockResizeEventListener2, times(2)).onResizeEvent(any());
+    }
+
+    @Test
+    public void removeAllResizeEventListeners_removesReformEventConsumer() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener1 = mock(ResizeEventListener.class);
+        ResizeEventListener mockResizeEventListener2 = mock(ResizeEventListener.class);
+        FakeScheduledExecutorService executorService = new FakeScheduledExecutorService();
+
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener1);
+        resizableComponent.addResizeEventListener(executorService, mockResizeEventListener2);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        assertThat(executorService.hasNext()).isTrue();
+        executorService.runAll();
+        verify(mockResizeEventListener1).onResizeEvent(any());
+        verify(mockResizeEventListener2).onResizeEvent(any());
+
+        resizableComponent.removeResizeEventListener(mockResizeEventListener1);
+        resizableComponent.removeResizeEventListener(mockResizeEventListener2);
+
+        assertThat(entity.reformEventConsumerMap).isEmpty();
+    }
+
+    @Test
+    public void removeResizableComponent_clearsResizeReformOptionsAndResizeEventListeners() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener = mock(ResizeEventListener.class);
+
+        resizableComponent.addResizeEventListener(directExecutor(), mockResizeEventListener);
+        assertThat(resizableComponent.reformEventConsumer).isNotNull();
+
+        entity.removeComponent(resizableComponent);
+        assertThat(node.getReformOptions().getEnabledReform() & ReformOptions.ALLOW_RESIZE)
+                .isEqualTo(0);
+        assertThat(entity.reformEventConsumerMap).isEmpty();
+    }
+
+    @Test
+    public void addMoveAndResizeComponents_setsCombinedReformsOptions() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        true,
+                        true,
+                        ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ true,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor,
+                        fakeExtensions,
+                        new Dimensions(0f, 0f, 0f),
+                        new Dimensions(5f, 5f, 5f));
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_MOVE | ReformOptions.ALLOW_RESIZE);
+    }
+
+    @Test
+    public void addMoveAndResizeComponents_removingMoveKeepsResize() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        true,
+                        true,
+                        ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ true,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor,
+                        fakeExtensions,
+                        new Dimensions(0f, 0f, 0f),
+                        new Dimensions(5f, 5f, 5f));
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        MoveEventListener moveEventListener = mock(MoveEventListener.class);
+        movableComponent.addMoveEventListener(directExecutor(), moveEventListener);
+        ResizeEventListener resizeEventListener = mock(ResizeEventListener.class);
+        resizableComponent.addResizeEventListener(directExecutor(), resizeEventListener);
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_MOVE | ReformOptions.ALLOW_RESIZE);
+
+        entity.removeComponent(movableComponent);
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_RESIZE);
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(resizeEventListener).onResizeEvent(any());
+
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(moveEventListener, never()).onMoveEvent(any());
+    }
+
+    @Test
+    public void addMoveAndResizeComponents_removingResizeKeepsMove() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        MovableComponentImpl movableComponent =
+                new MovableComponentImpl(
+                        true,
+                        true,
+                        ImmutableSet.of(),
+                        /* shouldDisposeParentAnchor= */ true,
+                        perceptionLibrary,
+                        fakeExtensions,
+                        activitySpaceImpl,
+                        activitySpaceRoot,
+                        perceptionSpaceActivityPose,
+                        entityManager,
+                        panelShadowRenderer,
+                        fakeExecutor);
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor,
+                        fakeExtensions,
+                        new Dimensions(0f, 0f, 0f),
+                        new Dimensions(5f, 5f, 5f));
+        assertThat(entity.addComponent(movableComponent)).isTrue();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        MoveEventListener moveEventListener = mock(MoveEventListener.class);
+        movableComponent.addMoveEventListener(directExecutor(), moveEventListener);
+        ResizeEventListener resizeEventListener = mock(ResizeEventListener.class);
+        resizableComponent.addResizeEventListener(directExecutor(), resizeEventListener);
+        FakeNode node = (FakeNode) entity.getNode();
+        assertThat(node.getReformOptions().getEnabledReform())
+                .isEqualTo(ReformOptions.ALLOW_MOVE | ReformOptions.ALLOW_RESIZE);
+
+        entity.removeComponent(resizableComponent);
+        assertThat(node.getReformOptions().getEnabledReform()).isEqualTo(ReformOptions.ALLOW_MOVE);
+
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_MOVE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(moveEventListener).onMoveEvent(any());
+
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(resizeEventListener, never()).onResizeEvent(any());
+    }
+
+    @Test
+    public void resizableComponent_canAttachAgainAfterDetach() {
+        Entity entity = createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        entity.removeComponent(resizableComponent);
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+    }
+
+    @Test
+    public void resizableComponent_hidesEntityDuringResize() {
+        AndroidXrEntity entity = (AndroidXrEntity) createTestEntity();
+        assertThat(entity).isNotNull();
+        ResizableComponentImpl resizableComponent =
+                new ResizableComponentImpl(
+                        fakeExecutor, fakeExtensions, kMinDimensions, kMaxDimensions);
+        assertThat(resizableComponent).isNotNull();
+        assertThat(entity.addComponent(resizableComponent)).isTrue();
+        entity.setAlpha(0.9f);
+        assertThat(entity.getAlpha()).isEqualTo(0.9f);
+        FakeNode node = (FakeNode) entity.getNode();
+        ResizeEventListener mockResizeEventListener = mock(ResizeEventListener.class);
+
+        resizableComponent.addResizeEventListener(directExecutor(), mockResizeEventListener);
+        assertThat(node.getReformOptions().getEventCallback()).isNotNull();
+        assertThat(node.getReformOptions().getEventExecutor()).isNotNull();
+        assertThat(entity.reformEventConsumerMap).isNotEmpty();
+
+        // Start the resize.
+        FakeReformEvent reformEvent = new FakeReformEvent();
+        reformEvent.setType(ReformEvent.REFORM_TYPE_RESIZE);
+        reformEvent.setState(ReformEvent.REFORM_STATE_START);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        ArgumentCaptor<ResizeEvent> resizeEventCaptor = ArgumentCaptor.forClass(ResizeEvent.class);
+        verify(mockResizeEventListener).onResizeEvent(resizeEventCaptor.capture());
+        ResizeEvent resizeEvent = resizeEventCaptor.getValue();
+        assertThat(resizeEvent.resizeState).isEqualTo(ResizeEvent.RESIZE_STATE_START);
+        assertThat(node.getAlpha()).isEqualTo(0.0f);
+
+        // End the resize.
+        reformEvent.setState(ReformEvent.REFORM_STATE_END);
+        node.getReformOptions()
+                .getEventExecutor()
+                .execute(() -> node.getReformOptions().getEventCallback().accept(reformEvent));
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(mockResizeEventListener, times(2)).onResizeEvent(resizeEventCaptor.capture());
+        resizeEvent = resizeEventCaptor.getAllValues().get(2);
+        assertThat(resizeEvent.resizeState).isEqualTo(ResizeEvent.RESIZE_STATE_END);
+        assertThat(node.getAlpha()).isEqualTo(0.9f);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/RuntimeUtilsTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/RuntimeUtilsTest.java
new file mode 100644
index 0000000..c8d8f88
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/RuntimeUtilsTest.java
@@ -0,0 +1,493 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.util.Log;
+
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.extensions.node.ReformEvent;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.InputEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneSemantic;
+import androidx.xr.scenecore.JxrPlatformAdapter.PlaneType;
+import androidx.xr.scenecore.JxrPlatformAdapter.ResizeEvent;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialCapabilities;
+import androidx.xr.scenecore.impl.perception.Plane;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakePassthroughVisibilityState;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.junit.rules.ExpectedLogMessagesRule;
+
+import java.util.regex.Pattern;
+
+@RunWith(RobolectricTestRunner.class)
+public final class RuntimeUtilsTest {
+
+    @Rule
+    public final ExpectedLogMessagesRule expectedLogMessagesRule = new ExpectedLogMessagesRule();
+
+    @Test
+    public void getPlaneTypeHorizontal_returnsHorizontal() {
+        assertThat(RuntimeUtils.getPlaneType(PlaneType.HORIZONTAL))
+                .isEqualTo(Plane.Type.HORIZONTAL_UPWARD_FACING);
+    }
+
+    @Test
+    public void getPlaneTypeVertical_returnsVertical() {
+        assertThat(RuntimeUtils.getPlaneType(PlaneType.VERTICAL)).isEqualTo(Plane.Type.VERTICAL);
+    }
+
+    @Test
+    public void getPlaneTypeAny_returnsArbitrary() {
+        assertThat(RuntimeUtils.getPlaneType(PlaneType.ANY)).isEqualTo(Plane.Type.ARBITRARY);
+    }
+
+    @Test
+    public void getPlaneTypeHorizontalUpwardFacingFromPerception_returnsHorizontal() {
+        assertThat(RuntimeUtils.getPlaneType(Plane.Type.HORIZONTAL_UPWARD_FACING))
+                .isEqualTo(PlaneType.HORIZONTAL);
+    }
+
+    @Test
+    public void getPlaneTypeHorizontalDownwardFacingFromPerception_returnsHorizontal() {
+        assertThat(RuntimeUtils.getPlaneType(Plane.Type.HORIZONTAL_DOWNWARD_FACING))
+                .isEqualTo(PlaneType.HORIZONTAL);
+    }
+
+    @Test
+    public void getPlaneTypeVerticalFromPerception_returnsVertical() {
+        assertThat(RuntimeUtils.getPlaneType(Plane.Type.VERTICAL)).isEqualTo(PlaneType.VERTICAL);
+    }
+
+    @Test
+    public void getPlaneTypeArbitraryFromPerception_returnsAny() {
+        assertThat(RuntimeUtils.getPlaneType(PlaneType.ANY)).isEqualTo(Plane.Type.ARBITRARY);
+    }
+
+    @Test
+    public void getPlaneLabelWall_returnsWall() {
+        assertThat(RuntimeUtils.getPlaneLabel(PlaneSemantic.WALL)).isEqualTo(Plane.Label.WALL);
+    }
+
+    @Test
+    public void getPlaneLabelFloor_returnsFloor() {
+        assertThat(RuntimeUtils.getPlaneLabel(PlaneSemantic.FLOOR)).isEqualTo(Plane.Label.FLOOR);
+    }
+
+    @Test
+    public void getPlaneLabelCeiling_returnsCeiling() {
+        assertThat(RuntimeUtils.getPlaneLabel(PlaneSemantic.CEILING))
+                .isEqualTo(Plane.Label.CEILING);
+    }
+
+    @Test
+    public void getPlaneLabelTable_returnsTable() {
+        assertThat(RuntimeUtils.getPlaneLabel(PlaneSemantic.TABLE)).isEqualTo(Plane.Label.TABLE);
+    }
+
+    @Test
+    public void getPlaneLabelAny_returnsUnknown() {
+        assertThat(RuntimeUtils.getPlaneLabel(PlaneSemantic.ANY)).isEqualTo(Plane.Label.UNKNOWN);
+    }
+
+    @Test
+    public void getPlaneSemanticlWall_returnsWall() {
+        assertThat(RuntimeUtils.getPlaneSemantic(Plane.Label.WALL)).isEqualTo(PlaneSemantic.WALL);
+    }
+
+    @Test
+    public void getPlaneSemanticFloor_returnsFloor() {
+        assertThat(RuntimeUtils.getPlaneSemantic(Plane.Label.FLOOR)).isEqualTo(PlaneSemantic.FLOOR);
+    }
+
+    @Test
+    public void getPlaneSemanticCeiling_returnsCeiling() {
+        assertThat(RuntimeUtils.getPlaneSemantic(Plane.Label.CEILING))
+                .isEqualTo(PlaneSemantic.CEILING);
+    }
+
+    @Test
+    public void getPlaneSemanticTable_returnsTable() {
+        assertThat(RuntimeUtils.getPlaneSemantic(Plane.Label.TABLE)).isEqualTo(PlaneSemantic.TABLE);
+    }
+
+    @Test
+    public void getPlaneSemanticUnknown_returnsAny() {
+        assertThat(RuntimeUtils.getPlaneSemantic(Plane.Label.UNKNOWN)).isEqualTo(PlaneSemantic.ANY);
+    }
+
+    @Test
+    public void getMatrix_returnsMatrix() {
+        float[] expected = new float[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+        Mat4f matrix = new Mat4f(expected);
+
+        assertThat(RuntimeUtils.getMatrix(matrix).getData())
+                .usingExactEquality()
+                .containsExactly(new Matrix4(expected).getData())
+                .inOrder();
+    }
+
+    @Test
+    public void fromPerceptionPose_returnsPose() {
+        Pose expectedPose = new Pose(new Vector3(1, 2, 3), new Quaternion(0, 0, 0, 1));
+        androidx.xr.scenecore.impl.perception.Pose perceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(1, 2, 3, 0, 0, 0, 1);
+
+        assertPose(RuntimeUtils.fromPerceptionPose(perceptionPose), expectedPose);
+    }
+
+    @Test
+    public void poseToPerceptionPose_returnsPerceptionPose() {
+        androidx.xr.scenecore.impl.perception.Pose expectedPerceptionPose =
+                new androidx.xr.scenecore.impl.perception.Pose(1, 2, 3, 0, 0, 0, 1);
+        Pose pose = new Pose(new Vector3(1, 2, 3), new Quaternion(0, 0, 0, 1));
+
+        assertThat(RuntimeUtils.poseToPerceptionPose(pose)).isEqualTo(expectedPerceptionPose);
+    }
+
+    private static final int CAPS_ALL = -1;
+    private static final int CAPS_NONE = -2;
+
+    private androidx.xr.extensions.space.SpatialCapabilities getCapabilities(int... caps) {
+        return new androidx.xr.extensions.space.SpatialCapabilities() {
+            @Override
+            public boolean get(int capQuery) {
+                for (int cap : caps) {
+                    if (cap == CAPS_ALL || cap == capQuery) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        };
+    }
+
+    @Test
+    public void convertSpatialCapabilities_noCapabilities() {
+        androidx.xr.extensions.space.SpatialCapabilities extensionCapabilities =
+                getCapabilities(CAPS_NONE);
+        SpatialCapabilities caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+    }
+
+    @Test
+    public void convertSpatialCapabilities_allCapabilities() {
+        androidx.xr.extensions.space.SpatialCapabilities extensionCapabilities =
+                getCapabilities(CAPS_ALL);
+        SpatialCapabilities caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isTrue();
+    }
+
+    @Test
+    public void convertSpatialCapabilities_singleCapability() {
+        // check conversions of a few different instances of the extensions SpatialCapabilities that
+        // each have exactly one capability.
+        androidx.xr.extensions.space.SpatialCapabilities extensionCapabilities =
+                getCapabilities(
+                        androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_UI_CAPABLE);
+        SpatialCapabilities caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+
+        extensionCapabilities =
+                getCapabilities(
+                        androidx.xr.extensions.space.SpatialCapabilities
+                                .SPATIAL_3D_CONTENTS_CAPABLE);
+        caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+
+        extensionCapabilities =
+                getCapabilities(
+                        androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_AUDIO_CAPABLE);
+        caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+    }
+
+    @Test
+    public void convertSpatialCapabilities_mixedCapabilities() {
+        // Check conversions for a couple of different combinations of capabilities.
+        androidx.xr.extensions.space.SpatialCapabilities extensionCapabilities =
+                getCapabilities(
+                        androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_AUDIO_CAPABLE,
+                        androidx.xr.extensions.space.SpatialCapabilities
+                                .SPATIAL_3D_CONTENTS_CAPABLE);
+        SpatialCapabilities caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isFalse();
+
+        extensionCapabilities =
+                extensionCapabilities =
+                        getCapabilities(
+                                androidx.xr.extensions.space.SpatialCapabilities.SPATIAL_UI_CAPABLE,
+                                androidx.xr.extensions.space.SpatialCapabilities
+                                        .PASSTHROUGH_CONTROL_CAPABLE,
+                                androidx.xr.extensions.space.SpatialCapabilities
+                                        .APP_ENVIRONMENTS_CAPABLE,
+                                androidx.xr.extensions.space.SpatialCapabilities
+                                        .SPATIAL_ACTIVITY_EMBEDDING_CAPABLE);
+        caps = RuntimeUtils.convertSpatialCapabilities(extensionCapabilities);
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_UI)).isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_3D_CONTENT)).isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_PASSTHROUGH_CONTROL))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_APP_ENVIRONMENT))
+                .isTrue();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_SPATIAL_AUDIO))
+                .isFalse();
+        assertThat(caps.hasCapability(SpatialCapabilities.SPATIAL_CAPABILITY_EMBED_ACTIVITY))
+                .isTrue();
+    }
+
+    @Test
+    public void getIsSpatialEnvironmentPreferenceActive_convertsFromExtensionState() {
+        assertThat(
+                        RuntimeUtils.getIsSpatialEnvironmentPreferenceActive(
+                                EnvironmentVisibilityState.INVISIBLE))
+                .isFalse();
+
+        assertThat(
+                        RuntimeUtils.getIsSpatialEnvironmentPreferenceActive(
+                                EnvironmentVisibilityState.HOME_VISIBLE))
+                .isFalse();
+
+        assertThat(
+                        RuntimeUtils.getIsSpatialEnvironmentPreferenceActive(
+                                EnvironmentVisibilityState.APP_VISIBLE))
+                .isTrue();
+    }
+
+    @Test
+    public void getPassthroughOpacity_returnsZeroFromDisabledExtensionState() {
+        PassthroughVisibilityState passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.DISABLED, 0.0f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(0.0f);
+
+        passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.DISABLED, 1.0f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(0.0f);
+    }
+
+    @Test
+    public void getPassthroughOpacity_convertsValidValuesFromExtensionState() {
+        PassthroughVisibilityState passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.HOME, 0.5f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(0.5f);
+
+        passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 0.75f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(0.75f);
+
+        passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.SYSTEM, 1.0f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(1.0f);
+    }
+
+    @Test
+    public void getPassthroughOpacity_convertsInvalidValuesFromExtensionStateToOneAndLogsError() {
+        PassthroughVisibilityState passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.HOME, 0.0f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(1.0f);
+
+        passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, -0.0000001f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(1.0f);
+
+        passthroughVisibilityState =
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.SYSTEM, -1.0f);
+        assertThat(RuntimeUtils.getPassthroughOpacity(passthroughVisibilityState)).isEqualTo(1.0f);
+
+        expectedLogMessagesRule.expectLogMessagePattern(
+                Log.ERROR,
+                "RuntimeUtils",
+                Pattern.compile(".* Opacity should be greater than zero.*"));
+    }
+
+    @Test
+    public void getInputEventSource_convertsFromExtensionSource() {
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_UNKNOWN))
+                .isEqualTo(InputEvent.SOURCE_UNKNOWN);
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_HEAD))
+                .isEqualTo(InputEvent.SOURCE_HEAD);
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_CONTROLLER))
+                .isEqualTo(InputEvent.SOURCE_CONTROLLER);
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_HANDS))
+                .isEqualTo(InputEvent.SOURCE_HANDS);
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_MOUSE))
+                .isEqualTo(InputEvent.SOURCE_MOUSE);
+        assertThat(
+                        RuntimeUtils.getInputEventSource(
+                                androidx.xr.extensions.node.InputEvent.SOURCE_GAZE_AND_GESTURE))
+                .isEqualTo(InputEvent.SOURCE_GAZE_AND_GESTURE);
+    }
+
+    @Test
+    public void getInputEventSource_throwsExceptionForInvalidValue() {
+        assertThrows(IllegalArgumentException.class, () -> RuntimeUtils.getInputEventSource(100));
+    }
+
+    @Test
+    public void getInputEventPointerType_convertsFromExtensionPointerType() {
+        assertThat(
+                        RuntimeUtils.getInputEventPointerType(
+                                androidx.xr.extensions.node.InputEvent.POINTER_TYPE_DEFAULT))
+                .isEqualTo(InputEvent.POINTER_TYPE_DEFAULT);
+        assertThat(
+                        RuntimeUtils.getInputEventPointerType(
+                                androidx.xr.extensions.node.InputEvent.POINTER_TYPE_LEFT))
+                .isEqualTo(InputEvent.POINTER_TYPE_LEFT);
+        assertThat(
+                        RuntimeUtils.getInputEventPointerType(
+                                androidx.xr.extensions.node.InputEvent.POINTER_TYPE_RIGHT))
+                .isEqualTo(InputEvent.POINTER_TYPE_RIGHT);
+    }
+
+    @Test
+    public void getInputEventPointerType_throwsExceptionForInvalidValue() {
+        assertThrows(
+                IllegalArgumentException.class, () -> RuntimeUtils.getInputEventPointerType(100));
+    }
+
+    @Test
+    public void getInputEventAction_convertsFromExtensionAction() {
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_DOWN))
+                .isEqualTo(InputEvent.ACTION_DOWN);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_UP))
+                .isEqualTo(InputEvent.ACTION_UP);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_MOVE))
+                .isEqualTo(InputEvent.ACTION_MOVE);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_CANCEL))
+                .isEqualTo(InputEvent.ACTION_CANCEL);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_HOVER_MOVE))
+                .isEqualTo(InputEvent.ACTION_HOVER_MOVE);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_HOVER_ENTER))
+                .isEqualTo(InputEvent.ACTION_HOVER_ENTER);
+        assertThat(
+                        RuntimeUtils.getInputEventAction(
+                                androidx.xr.extensions.node.InputEvent.ACTION_HOVER_EXIT))
+                .isEqualTo(InputEvent.ACTION_HOVER_EXIT);
+    }
+
+    @Test
+    public void getInputEventAction_throwsExceptionForInvalidValue() {
+        assertThrows(IllegalArgumentException.class, () -> RuntimeUtils.getInputEventAction(100));
+    }
+
+    @Test
+    public void getResizeEventState_convertsFromExtensionResizeState() {
+        assertThat(RuntimeUtils.getResizeEventState(ReformEvent.REFORM_STATE_UNKNOWN))
+                .isEqualTo(ResizeEvent.RESIZE_STATE_UNKNOWN);
+        assertThat(RuntimeUtils.getResizeEventState(ReformEvent.REFORM_STATE_START))
+                .isEqualTo(ResizeEvent.RESIZE_STATE_START);
+        assertThat(RuntimeUtils.getResizeEventState(ReformEvent.REFORM_STATE_ONGOING))
+                .isEqualTo(ResizeEvent.RESIZE_STATE_ONGOING);
+        assertThat(RuntimeUtils.getResizeEventState(ReformEvent.REFORM_STATE_END))
+                .isEqualTo(ResizeEvent.RESIZE_STATE_END);
+    }
+
+    @Test
+    public void getResizeEventState_throwsExceptionForInvalidValue() {
+        assertThrows(IllegalArgumentException.class, () -> RuntimeUtils.getResizeEventState(100));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImplTest.java
new file mode 100644
index 0000000..03ca1ab
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SoundPoolExtensionsWrapperImplTest.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.media.SoundPool;
+
+import androidx.xr.extensions.node.Node;
+import androidx.xr.scenecore.JxrPlatformAdapter;
+import androidx.xr.scenecore.JxrPlatformAdapter.SoundPoolExtensionsWrapper;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatializerConstants;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSoundPoolExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialAudioExtensions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class SoundPoolExtensionsWrapperImplTest {
+
+    private static final int TEST_SOUND_ID = 0;
+    private static final float TEST_VOLUME = 0F;
+    private static final int TEST_PRIORITY = 0;
+    private static final int TEST_LOOP = 0;
+    private static final float TEST_RATE = 0F;
+
+    FakeXrExtensions fakeXrExtensions;
+    FakeSpatialAudioExtensions fakeSpatialAudioExtensions;
+    FakeSoundPoolExtensions fakeSoundPoolExtensions;
+
+    @Before
+    public void setUp() {
+        fakeXrExtensions = new FakeXrExtensions();
+        fakeSpatialAudioExtensions = fakeXrExtensions.fakeSpatialAudioExtensions;
+        fakeSoundPoolExtensions = fakeSpatialAudioExtensions.soundPoolExtensions;
+    }
+
+    @Test
+    public void playWithPointSource_callsExtensionsPlayWithPointSource() {
+        int expected = 123;
+
+        Node fakeNode = new FakeXrExtensions().createNode();
+        AndroidXrEntity entity = mock(AndroidXrEntity.class);
+        when(entity.getNode()).thenReturn(fakeNode);
+        JxrPlatformAdapter.PointSourceAttributes rtAttributes =
+                new JxrPlatformAdapter.PointSourceAttributes(entity);
+
+        SoundPool soundPool = new SoundPool.Builder().build();
+
+        fakeSoundPoolExtensions.setPlayAsPointSourceResult(expected);
+        SoundPoolExtensionsWrapper wrapper =
+                new SoundPoolExtensionsWrapperImpl(fakeSoundPoolExtensions);
+        int actual =
+                wrapper.play(
+                        soundPool,
+                        TEST_SOUND_ID,
+                        rtAttributes,
+                        TEST_VOLUME,
+                        TEST_PRIORITY,
+                        TEST_LOOP,
+                        TEST_RATE);
+
+        assertThat(actual).isEqualTo(expected);
+    }
+
+    @Test
+    public void playWithSoundField_callsExtensionsPlayWithSoundField() {
+        int expected = 312;
+
+        SoundPool soundPool = new SoundPool.Builder().build();
+
+        fakeSoundPoolExtensions.setPlayAsSoundFieldResult(expected);
+        SoundPoolExtensionsWrapper wrapper =
+                new SoundPoolExtensionsWrapperImpl(fakeSoundPoolExtensions);
+        JxrPlatformAdapter.SoundFieldAttributes attributes =
+                new JxrPlatformAdapter.SoundFieldAttributes(
+                        JxrPlatformAdapter.SpatializerConstants.AMBISONICS_ORDER_THIRD_ORDER);
+
+        int actual =
+                wrapper.play(
+                        soundPool,
+                        TEST_SOUND_ID,
+                        attributes,
+                        TEST_VOLUME,
+                        TEST_PRIORITY,
+                        TEST_LOOP,
+                        TEST_RATE);
+
+        assertThat(actual).isEqualTo(expected);
+    }
+
+    @Test
+    public void getSpatialSourceType_returnsFromExtensions() {
+        int expected = SpatializerConstants.SOURCE_TYPE_SOUND_FIELD;
+        SoundPool soundPool = new SoundPool.Builder().build();
+
+        fakeSoundPoolExtensions.setSourceType(expected);
+        SoundPoolExtensionsWrapper wrapper =
+                new SoundPoolExtensionsWrapperImpl(fakeSoundPoolExtensions);
+        int actualSourceType = wrapper.getSpatialSourceType(soundPool, /* streamId= */ 0);
+        assertThat(actualSourceType).isEqualTo(SpatializerConstants.SOURCE_TYPE_SOUND_FIELD);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SpatialEnvironmentImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SpatialEnvironmentImplTest.java
new file mode 100644
index 0000000..eb2c039
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SpatialEnvironmentImplTest.java
@@ -0,0 +1,570 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+
+import androidx.xr.extensions.environment.EnvironmentVisibilityState;
+import androidx.xr.extensions.environment.PassthroughVisibilityState;
+import androidx.xr.extensions.space.SpatialState;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetPassthroughOpacityPreferenceResult;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SetSpatialEnvironmentPreferenceResult;
+import androidx.xr.scenecore.JxrPlatformAdapter.SpatialEnvironment.SpatialEnvironmentPreference;
+import androidx.xr.scenecore.testing.FakeImpressApi;
+import androidx.xr.scenecore.testing.FakeXrExtensions;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeEnvironmentVisibilityState;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakePassthroughVisibilityState;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeSpatialState;
+
+import com.google.androidxr.splitengine.SplitEngineSubspaceManager;
+import com.google.androidxr.splitengine.SubspaceNode;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+// Technically this doesn't need to be a Robolectric test, since it doesn't directly depend on
+// any Android subsystems. However, we're currently using an Android test runner for consistency
+// with other Android XR impl tests in this directory.
+/**
+ * Unit tests for the AndroidXR implementation of JXRCore's SpatialEnvironment module.
+ *
+ * <p>TODO(b/326748782): Update the FakeExtensions to support better asserts.
+ */
+@RunWith(RobolectricTestRunner.class)
+@SuppressWarnings({"deprecation", "UnnecessarilyFullyQualified"}) // TODO(b/373435470): Remove
+public final class SpatialEnvironmentImplTest {
+    private static final int SUBSPACE_ID = 5;
+    private final FakeImpressApi fakeImpressApi = new FakeImpressApi();
+    private ActivityController<Activity> activityController;
+    private Activity activity;
+    private FakeXrExtensions fakeExtensions = null;
+    private FakeNode subspaceNode;
+    private SubspaceNode expectedSubspace;
+    private SpatialEnvironmentImpl environment = null;
+    private SplitEngineSubspaceManager splitEngineSubspaceManager;
+
+    @Before
+    public void setUp() {
+        activityController = Robolectric.buildActivity(Activity.class);
+        activity = activityController.create().start().get();
+        // Reset our state.
+        fakeExtensions = new FakeXrExtensions();
+        FakeNode fakeSceneRootNode = (FakeNode) fakeExtensions.createNode();
+        subspaceNode = (FakeNode) fakeExtensions.createNode();
+        expectedSubspace = new SubspaceNode(SUBSPACE_ID, subspaceNode);
+
+        splitEngineSubspaceManager = Mockito.mock(SplitEngineSubspaceManager.class);
+
+        environment =
+                new SpatialEnvironmentImpl(
+                        activity,
+                        fakeExtensions,
+                        fakeSceneRootNode,
+                        this::getSpatialState,
+                        /* useSplitEngine= */ false);
+        environment.onSplitEngineReady(splitEngineSubspaceManager, fakeImpressApi);
+    }
+
+    private void setupSplitEngineEnvironmentImpl() {
+        FakeNode fakeSceneRootNode = (FakeNode) fakeExtensions.createNode();
+
+        when(splitEngineSubspaceManager.createSubspace(anyString(), anyInt()))
+                .thenReturn(expectedSubspace);
+
+        environment =
+                new SpatialEnvironmentImpl(
+                        activity,
+                        fakeExtensions,
+                        fakeSceneRootNode,
+                        this::getSpatialState,
+                        /* useSplitEngine= */ true);
+        environment.onSplitEngineReady(splitEngineSubspaceManager, fakeImpressApi);
+    }
+
+    @SuppressWarnings({"FutureReturnValueIgnored", "AndroidJdkLibsChecker"})
+    private androidx.xr.extensions.asset.EnvironmentToken fakeLoadEnvironment(String name) {
+        try {
+            return fakeExtensions.loadEnvironment(null, 0, 0, name).get();
+        } catch (Exception e) {
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            return null;
+        }
+    }
+
+    @SuppressWarnings({"FutureReturnValueIgnored", "AndroidJdkLibsChecker"})
+    private androidx.xr.extensions.asset.GltfModelToken fakeLoadGltfModel(String name) {
+        try {
+            return fakeExtensions.loadGltfModel(null, 0, 0, name).get();
+        } catch (Exception e) {
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            return null;
+        }
+    }
+
+    @SuppressWarnings({"FutureReturnValueIgnored", "AndroidJdkLibsChecker"})
+    private long fakeLoadGltfModelSplitEngine(String name) {
+        try {
+            return fakeImpressApi.loadGltfModel(name).get();
+        } catch (Exception e) {
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            return -1;
+        }
+    }
+
+    private SpatialState getSpatialState() {
+        return fakeExtensions.fakeSpatialState;
+    }
+
+    @Test
+    public void setPassthroughOpacityPreference() {
+        environment.setPassthroughOpacityPreference(null);
+        assertThat(environment.getPassthroughOpacityPreference()).isNull();
+
+        environment.setPassthroughOpacityPreference(0.1f);
+        assertThat(environment.getPassthroughOpacityPreference()).isEqualTo(0.1f);
+    }
+
+    @Test
+    public void setPassthroughOpacityPreferenceNearOrUnderZero_getsZeroOpacity() {
+        // Opacity values below 1% should be treated as zero.
+        environment.setPassthroughOpacityPreference(0.009f);
+        assertThat(environment.getPassthroughOpacityPreference()).isEqualTo(0.0f);
+
+        environment.setPassthroughOpacityPreference(-0.1f);
+        assertThat(environment.getPassthroughOpacityPreference()).isEqualTo(0.0f);
+    }
+
+    @Test
+    public void setPassthroughOpacityPreferenceNearOrOverOne_getsFullOpacity() {
+        // Opacity values above 99% should be treated as full opacity.
+        environment.setPassthroughOpacityPreference(0.991f);
+        assertThat(environment.getPassthroughOpacityPreference()).isEqualTo(1.0f);
+
+        environment.setPassthroughOpacityPreference(1.1f);
+        assertThat(environment.getPassthroughOpacityPreference()).isEqualTo(1.0f);
+    }
+
+    @Test
+    public void setPassthroughOpacityPreference_returnsAccordingToSpatialCapabilities() {
+        // Change should be applied if the spatial capabilities allow it, otherwise should be
+        // pending.
+        fakeExtensions.fakeSpatialState.setAllSpatialCapabilities(true);
+        assertThat(environment.setPassthroughOpacityPreference(0.5f))
+                .isEqualTo(SetPassthroughOpacityPreferenceResult.CHANGE_APPLIED);
+
+        fakeExtensions.fakeSpatialState.setAllSpatialCapabilities(false);
+        assertThat(environment.setPassthroughOpacityPreference(0.6f))
+                .isEqualTo(SetPassthroughOpacityPreferenceResult.CHANGE_PENDING);
+    }
+
+    @Test
+    public void getCurrentPassthroughOpacity_returnsZeroInitially() {
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.0f);
+    }
+
+    @Test
+    public void onPassthroughOpacityChangedListener_firesOnPassthroughOpacityChange() {
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Float> listener1 = (Consumer<Float>) mock(Consumer.class);
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Float> listener2 = (Consumer<Float>) mock(Consumer.class);
+
+        environment.addOnPassthroughOpacityChangedListener(listener1);
+        environment.addOnPassthroughOpacityChangedListener(listener2);
+
+        environment.firePassthroughOpacityChangedEvent(0.5f);
+        verify(listener1).accept(0.5f);
+        verify(listener2).accept(0.5f);
+
+        environment.removeOnPassthroughOpacityChangedListener(listener1);
+        environment.firePassthroughOpacityChangedEvent(0.0f);
+        verify(listener1)
+                .accept(any()); // Verify the removed listener was called exactly once total
+        verify(listener2).accept(0.0f); // Verify the active listener was called again with false
+    }
+
+    @Test
+    public void getSpatialEnvironmentPreference_returnsSetSpatialEnvironmentPreference() {
+        SpatialEnvironmentPreference preference = mock(SpatialEnvironmentPreference.class);
+        environment.setSpatialEnvironmentPreference(preference);
+        assertThat(environment.getSpatialEnvironmentPreference()).isEqualTo(preference);
+    }
+
+    @Test
+    public void setSpatialEnvironmentPreference_returnsAppliedWhenCapable() {
+        // Change should be applied if the spatial capabilities allow it, otherwise should be
+        // pending.
+        fakeExtensions.fakeSpatialState.setAllSpatialCapabilities(true);
+        SpatialEnvironmentPreference preference = mock(SpatialEnvironmentPreference.class);
+        assertThat(environment.setSpatialEnvironmentPreference(preference))
+                .isEqualTo(SetSpatialEnvironmentPreferenceResult.CHANGE_APPLIED);
+
+        fakeExtensions.fakeSpatialState.setAllSpatialCapabilities(false);
+        preference = mock(SpatialEnvironmentPreference.class);
+        assertThat(environment.setSpatialEnvironmentPreference(preference))
+                .isEqualTo(SetSpatialEnvironmentPreferenceResult.CHANGE_PENDING);
+    }
+
+    @Test
+    public void setSpatialEnvironmentPreferenceNull_removesEnvironment() {
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        androidx.xr.extensions.asset.GltfModelToken gltf = fakeLoadGltfModel("fakeGltfModel");
+
+        // Ensure that an environment is set.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImpl(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        FakeNode geometryNode = fakeExtensions.testGetNodeWithGltfToken(gltf);
+
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNode).isNotNull();
+
+        assertThat(skyboxNode.getParent()).isNotNull();
+        assertThat(geometryNode.getParent()).isNotNull();
+
+        // Ensure environment is removed
+        environment.setSpatialEnvironmentPreference(null);
+
+        assertThat(skyboxNode.getParent()).isNull();
+        assertThat(geometryNode.getParent()).isNull();
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNull();
+    }
+
+    @Test
+    public void setSpatialEnvironmentPreferenceNullWithSplitEngine_removesEnvironment() {
+        setupSplitEngineEnvironmentImpl();
+
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        long gltf = fakeLoadGltfModelSplitEngine("fakeGltfModel");
+
+        // Ensure that an environment is set.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImplSplitEngine(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        List<Integer> geometryNodes = fakeImpressApi.getImpressNodesForToken(gltf);
+
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNodes).isNotEmpty();
+
+        assertThat(skyboxNode.getParent()).isNotNull();
+        assertThat(fakeImpressApi.impressNodeHasParent(geometryNodes.get(0))).isTrue();
+
+        // Ensure environment is removed
+        environment.setSpatialEnvironmentPreference(null);
+
+        assertThat(skyboxNode.getParent()).isNull();
+        // TODO: b/354711945 - Uncomment when we can test the SetGeometrySplitEngine(null) path.
+        // assertThat(fakeImpressApi.impressNodeHasParent(geometryNodes.get(0))).isFalse();
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNull();
+    }
+
+    @Test
+    public void
+            setSpatialEnvironmentPreferenceWithNullSkyboxAndGeometry_doesNotDetachEnvironment() {
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        androidx.xr.extensions.asset.GltfModelToken gltf = fakeLoadGltfModel("fakeGltfModel");
+
+        // Ensure that an environment is set.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImpl(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        FakeNode geometryNode = fakeExtensions.testGetNodeWithGltfToken(gltf);
+
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNode).isNotNull();
+
+        assertThat(skyboxNode.getParent()).isNotNull();
+        assertThat(geometryNode.getParent()).isNotNull();
+
+        // Ensure environment is not removed if both skybox and geometry are updated to null.
+        environment.setSpatialEnvironmentPreference(new SpatialEnvironmentPreference(null, null));
+
+        assertThat(skyboxNode.getParent()).isNull();
+        assertThat(geometryNode.getParent()).isNull();
+
+        // TODO: b/371221872 - When the behavior is changed to set the black skybox, the fake env
+        // node
+        // will no longer be null and the commented out line should replace the uncommented line.
+        // This change isn't relevant for end users but it confirms the environment implementation
+        // is working as designed.
+        // assertThat(fakeExtensions.getFakeEnvironmentNode()).isNotNull();
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNull();
+    }
+
+    @Test
+    public void
+            setSpatialEnvironmentPreferenceWithNullSkyboxAndGeometrySplitEngine_doesNotDetachEnvironment() {
+        setupSplitEngineEnvironmentImpl();
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        long gltf = fakeLoadGltfModelSplitEngine("fakeGltfModel");
+
+        // Ensure that an environment is set.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImplSplitEngine(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        List<Integer> geometryNodes = fakeImpressApi.getImpressNodesForToken(gltf);
+
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNodes).isNotEmpty();
+
+        assertThat(skyboxNode.getParent()).isNotNull();
+        assertThat(fakeImpressApi.impressNodeHasParent(geometryNodes.get(0))).isTrue();
+
+        // Ensure environment is not removed if both skybox and geometry are updated to null.
+        environment.setSpatialEnvironmentPreference(new SpatialEnvironmentPreference(null, null));
+
+        assertThat(skyboxNode.getParent()).isNull();
+        // TODO: b/354711945 - Uncomment when we can test the SetGeometrySplitEngine(null) path.
+        // assertThat(fakeImpressApi.impressNodeHasParent(geometryNodes.get(0))).isFalse();
+
+        // TODO: b/371221872 - When the behavior is changed to set the black skybox, the fake env
+        // node
+        // will no longer be null and the commented out line should replace the uncommented line.
+        // This change isn't relevant for end users but it confirms the environment implementation
+        // is working as designed.
+        // assertThat(fakeExtensions.getFakeEnvironmentNode()).isNotNull();
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNull();
+    }
+
+    @Test
+    public void setNewSpatialEnvironmentPreference_replacesOldSpatialEnvironmentPreference() {
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        androidx.xr.extensions.asset.EnvironmentToken newExr =
+                fakeLoadEnvironment("newFakeEnvironment");
+        androidx.xr.extensions.asset.GltfModelToken gltf = fakeLoadGltfModel("fakeGltfModel");
+        androidx.xr.extensions.asset.GltfModelToken newGltf = fakeLoadGltfModel("newFakeGltfModel");
+
+        // Ensure that an environment is set a first time.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImpl(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        FakeNode geometryNode = fakeExtensions.testGetNodeWithGltfToken(gltf);
+
+        // Ensure that an environment is set a second time.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(newExr), new GltfModelResourceImpl(newGltf)));
+
+        FakeNode newSkyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(newExr);
+        FakeNode newGeometryNode = fakeExtensions.testGetNodeWithGltfToken(newGltf);
+
+        // None of the nodes should be null.
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNode).isNotNull();
+        assertThat(newSkyboxNode).isNotNull();
+        assertThat(newGeometryNode).isNotNull();
+
+        // Only the new nodes should have a parent.
+        assertThat(skyboxNode.getParent()).isNull();
+        assertThat(geometryNode.getParent()).isNull();
+        assertThat(newSkyboxNode.getParent()).isNotNull();
+        assertThat(newGeometryNode.getParent()).isNotNull();
+
+        // The names should be the same, but the resources should be different.
+        assertThat(skyboxNode.getEnvironment()).isNotEqualTo(newSkyboxNode.getEnvironment());
+        assertThat(skyboxNode.getName()).isEqualTo(SpatialEnvironmentImpl.SKYBOX_NODE_NAME);
+        assertThat(newSkyboxNode.getName()).isEqualTo(SpatialEnvironmentImpl.SKYBOX_NODE_NAME);
+        assertThat(geometryNode.getGltfModel()).isNotEqualTo(newGeometryNode.getGltfModel());
+        assertThat(geometryNode.getName()).isEqualTo(SpatialEnvironmentImpl.GEOMETRY_NODE_NAME);
+        assertThat(newGeometryNode.getName()).isEqualTo(SpatialEnvironmentImpl.GEOMETRY_NODE_NAME);
+
+        // The environment node should still be attached.
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNotNull();
+    }
+
+    @Test
+    public void
+            setNewSpatialEnvironmentPreferenceSplitEngine_replacesOldSpatialEnvironmentPreference() {
+        setupSplitEngineEnvironmentImpl();
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        androidx.xr.extensions.asset.EnvironmentToken newExr =
+                fakeLoadEnvironment("newFakeEnvironment");
+        long gltf = fakeLoadGltfModelSplitEngine("fakeGltfModel");
+        long newGltf = fakeLoadGltfModelSplitEngine("newFakeGltfModel");
+
+        // Ensure that an environment is set a first time.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImplSplitEngine(gltf)));
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        List<Integer> geometryNodes = fakeImpressApi.getImpressNodesForToken(gltf);
+
+        // Ensure that an environment is set a second time.
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(newExr),
+                        new GltfModelResourceImplSplitEngine(newGltf)));
+
+        FakeNode newSkyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(newExr);
+        List<Integer> newGeometryNodes = fakeImpressApi.getImpressNodesForToken(newGltf);
+
+        // None of the nodes should be null.
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNodes).isNotEmpty();
+        assertThat(newSkyboxNode).isNotNull();
+        assertThat(newGeometryNodes).isNotEmpty();
+
+        // Only the new nodes should have a parent.
+        assertThat(skyboxNode.getParent()).isNull();
+        // TODO: b/354711945 - Uncomment when we can test the SetGeometrySplitEngine(null) path.
+        // assertThat(fakeImpressApi.impressNodeHasParent(geometryNodes.get(0))).isFalse();
+        assertThat(newSkyboxNode.getParent()).isNotNull();
+        assertThat(fakeImpressApi.impressNodeHasParent(newGeometryNodes.get(0))).isTrue();
+
+        // The resources should be different.
+        assertThat(skyboxNode.getEnvironment()).isNotEqualTo(newSkyboxNode.getEnvironment());
+        assertThat(skyboxNode.getName()).isEqualTo(SpatialEnvironmentImpl.SKYBOX_NODE_NAME);
+        assertThat(newSkyboxNode.getName()).isEqualTo(SpatialEnvironmentImpl.SKYBOX_NODE_NAME);
+        assertThat(geometryNodes.get(0)).isNotEqualTo(newGeometryNodes.get(0));
+
+        // The environment node should still be attached.
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNotNull();
+    }
+
+    @Test
+    public void isSpatialEnvironmentPreferenceActive_defaultsToFalse() {
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+    }
+
+    @Test
+    public void onSpatialEnvironmentChangedListener_firesOnEnvironmentChange() {
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Boolean> listener1 = (Consumer<Boolean>) mock(Consumer.class);
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Boolean> listener2 = (Consumer<Boolean>) mock(Consumer.class);
+
+        environment.addOnSpatialEnvironmentChangedListener(listener1);
+        environment.addOnSpatialEnvironmentChangedListener(listener2);
+
+        environment.fireOnSpatialEnvironmentChangedEvent(true);
+        verify(listener1).accept(true);
+        verify(listener2).accept(true);
+
+        environment.removeOnSpatialEnvironmentChangedListener(listener1);
+        environment.fireOnSpatialEnvironmentChangedEvent(false);
+        verify(listener1)
+                .accept(any()); // Verify the removed listener was called exactly once total
+        verify(listener2).accept(false); // Verify the active listener was called again with false
+    }
+
+    @Test
+    public void dispose_clearsSpatialEnvironmentPreferenceListeners() {
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Boolean> listener = (Consumer<Boolean>) mock(Consumer.class);
+        environment.addOnSpatialEnvironmentChangedListener(listener);
+
+        environment.fireOnSpatialEnvironmentChangedEvent(true);
+        verify(listener).accept(true);
+
+        environment.dispose();
+        environment.fireOnSpatialEnvironmentChangedEvent(false);
+        verify(listener, never()).accept(false);
+    }
+
+    @Test
+    public void dispose_clearsPassthroughOpacityPreferenceListeners() {
+        @SuppressWarnings(value = "unchecked")
+        Consumer<Float> listener = (Consumer<Float>) mock(Consumer.class);
+        environment.addOnPassthroughOpacityChangedListener(listener);
+
+        environment.firePassthroughOpacityChangedEvent(1.0f);
+        verify(listener).accept(1.0f);
+
+        // Ensure the listener is called exactly once, even if the event is fired after dispose.
+        environment.dispose();
+        environment.firePassthroughOpacityChangedEvent(0.5f);
+        verify(listener).accept(any());
+    }
+
+    @Test
+    public void dispose_clearsResources() {
+        androidx.xr.extensions.asset.EnvironmentToken exr = fakeLoadEnvironment("fakeEnvironment");
+        androidx.xr.extensions.asset.GltfModelToken gltf = fakeLoadGltfModel("fakeGltfModel");
+        FakeSpatialState spatialState = new FakeSpatialState();
+
+        spatialState.setEnvironmentVisibility(
+                new FakeEnvironmentVisibilityState(EnvironmentVisibilityState.APP_VISIBLE));
+        spatialState.setPassthroughVisibility(
+                new FakePassthroughVisibilityState(PassthroughVisibilityState.APP, 0.5f));
+        environment.setSpatialState(spatialState);
+
+        environment.setSpatialEnvironmentPreference(
+                new SpatialEnvironmentPreference(
+                        new ExrImageResourceImpl(exr), new GltfModelResourceImpl(gltf)));
+        environment.setPassthroughOpacityPreference(0.5f);
+
+        FakeNode skyboxNode = fakeExtensions.testGetNodeWithEnvironmentToken(exr);
+        FakeNode geometryNode = fakeExtensions.testGetNodeWithGltfToken(gltf);
+
+        assertThat(skyboxNode).isNotNull();
+        assertThat(geometryNode).isNotNull();
+
+        assertThat(skyboxNode.getParent()).isNotNull();
+        assertThat(geometryNode.getParent()).isNotNull();
+
+        assertThat(environment.getSpatialEnvironmentPreference()).isNotNull();
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isTrue();
+
+        assertThat(environment.getPassthroughOpacityPreference()).isNotNull();
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.5f);
+
+        environment.dispose();
+        assertThat(skyboxNode.getParent()).isNull();
+        assertThat(geometryNode.getParent()).isNull();
+        assertThat(fakeExtensions.getFakeEnvironmentNode()).isNull();
+        assertThat(environment.getSpatialEnvironmentPreference()).isNull();
+        assertThat(environment.isSpatialEnvironmentPreferenceActive()).isFalse();
+        assertThat(environment.getPassthroughOpacityPreference()).isNull();
+        assertThat(environment.getCurrentPassthroughOpacity()).isEqualTo(0.0f);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SystemSpaceEntityImplTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SystemSpaceEntityImplTest.java
new file mode 100644
index 0000000..82ce0d4
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/SystemSpaceEntityImplTest.java
@@ -0,0 +1,333 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl;
+
+import static androidx.xr.runtime.testing.math.MathAssertions.assertPose;
+import static androidx.xr.runtime.testing.math.MathAssertions.assertVector3;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.verify;
+
+import androidx.xr.extensions.node.Mat4f;
+import androidx.xr.runtime.math.Matrix4;
+import androidx.xr.runtime.math.Pose;
+import androidx.xr.runtime.math.Quaternion;
+import androidx.xr.runtime.math.Vector3;
+import androidx.xr.scenecore.JxrPlatformAdapter.SystemSpaceEntity.OnSpaceUpdatedListener;
+import androidx.xr.scenecore.testing.FakeScheduledExecutorService;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeCloseable;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNode;
+import androidx.xr.scenecore.testing.FakeXrExtensions.FakeNodeTransform;
+
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * Abstract test class for {@link SystemSpaceEntityImpl} implementations.
+ *
+ * <p>Concrete implementations of {@link SystemSpaceEntityImpl} should extend this class and provide
+ * implementations for its abstract methods to ensure they comply with the abstract class.
+ */
+public abstract class SystemSpaceEntityImplTest {
+
+    /** Returns the {@link SystemSpaceEntityImpl} instance to test. */
+    protected abstract SystemSpaceEntityImpl getSystemSpaceEntityImpl();
+
+    /** Returns the default fake executor used by the {@link SystemSpaceEntityImpl} constructor. */
+    protected abstract FakeScheduledExecutorService getDefaultFakeExecutor();
+
+    /** Returns an arbitrary {@link AndroidXrEntity} instance which can set its parent. */
+    protected abstract AndroidXrEntity createChildAndroidXrEntity();
+
+    /** Returns the {@link ActivitySpaceImpl} instance which is the root of the Activity Space. */
+    protected abstract ActivitySpaceImpl getActivitySpaceEntity();
+
+    @Test
+    public void systemSpaceEntityImplConstructor_setsNodeTransformSubscription() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeScheduledExecutorService fakeExecutor = getDefaultFakeExecutor();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        assertThat(node.getTransformListener()).isNotNull();
+        assertThat(node.getTransformExecutor()).isEqualTo(fakeExecutor);
+        assertThat(systemSpaceEntity.nodeTransformCloseable).isNotNull();
+    }
+
+    @Test
+    public void dispose_closesNodeTransformSubscription() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeCloseable nodeTransformCloseable =
+                (FakeCloseable) systemSpaceEntity.nodeTransformCloseable;
+        assertThat(nodeTransformCloseable.isClosed()).isFalse();
+
+        systemSpaceEntity.dispose();
+        assertThat(nodeTransformCloseable.isClosed()).isTrue();
+    }
+
+    @Test
+    public void getPoseInOpenXrReferenceSpace_defaultsToNull() {
+        assertThat(getSystemSpaceEntityImpl().getPoseInOpenXrReferenceSpace()).isNull();
+    }
+
+    @Test
+    public void setOnSpaceUpdatedListener_callListenersOnActivitySpaceUpdated() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        OnSpaceUpdatedListener listener1 = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor1 = new FakeScheduledExecutorService();
+
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener1, executor1);
+        systemSpaceEntity.onSpaceUpdated();
+        assertThat(executor1.hasNext()).isTrue();
+        executor1.runAll();
+
+        verify(listener1).onSpaceUpdated();
+    }
+
+    @Test
+    public void
+            setOnSpaceUpdatedListener_multipleListeners_callLastListenersOnActivitySpaceUpdated() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        OnSpaceUpdatedListener listener1 = Mockito.mock(OnSpaceUpdatedListener.class);
+        OnSpaceUpdatedListener listener2 = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor1 = new FakeScheduledExecutorService();
+        FakeScheduledExecutorService executor2 = new FakeScheduledExecutorService();
+
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener1, executor1);
+        // This should override the previous listener.
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener2, executor2);
+        systemSpaceEntity.onSpaceUpdated();
+
+        assertThat(executor1.hasNext()).isFalse();
+        assertThat(executor2.hasNext()).isTrue();
+
+        executor1.runAll();
+        executor2.runAll();
+
+        verify(listener1, Mockito.never()).onSpaceUpdated();
+        verify(listener2).onSpaceUpdated();
+    }
+
+    @Test
+    public void setOnSpaceUpdatedListener_withNullExecutor_usesInternalExecutor() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeScheduledExecutorService fakeExecutor = getDefaultFakeExecutor();
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, null);
+        systemSpaceEntity.onSpaceUpdated();
+
+        assertThat(fakeExecutor.hasNext()).isTrue();
+        fakeExecutor.runAll();
+        verify(listener).onSpaceUpdated();
+    }
+
+    @Test
+    public void setOnSpaceUpdatedListener_withNullListener_noListenerCallOnActivitySpaceUpdated() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, executor);
+        systemSpaceEntity.setOnSpaceUpdatedListener(null, executor);
+
+        systemSpaceEntity.onSpaceUpdated();
+        executor.runAll();
+
+        verify(listener, Mockito.never()).onSpaceUpdated();
+    }
+
+    @Test
+    public void getPoseInOpenXrReferenceSpace_returnsPoseFromSubscribeToNodeTransform() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        Mat4f mat4f =
+                new Mat4f( // --                Column major, right handed 4x4 Transformation Matrix
+                        // with
+                        new float[] { // --         translation of (4, 8, 12) and rotation 90 (@)
+                            // around Z axis
+                            0f, 1f, 0f, 0f, // --     cos(@),   sin(@), 0,  0
+                            -1f, 0f, 0f, 0f, // --    -sin(@),  cos(@), 0,  0
+                            0f, 0f, 1f, 0f, // --     0,        0,      1,  0
+                            4f, 8f, 12f, 1f, // --    tx,       ty,     tz, 1
+                        });
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        node.sendTransformEvent(nodeTransformEvent);
+        getDefaultFakeExecutor().runAll();
+
+        Pose expectedPose =
+                new Pose(
+                        new Vector3(4f, 8f, 12f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 90f));
+
+        assertPose(systemSpaceEntity.getPoseInOpenXrReferenceSpace(), expectedPose);
+    }
+
+    @Test
+    public void setOnSpaceUpdatedListener_callsListenerOnNodeTransformEvent() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        Mat4f mat4f = new Mat4f(Matrix4.Identity.getData());
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, executor);
+
+        node.sendTransformEvent(nodeTransformEvent);
+        getDefaultFakeExecutor().runAll();
+
+        assertThat(executor.hasNext()).isTrue();
+        executor.runAll();
+
+        verify(listener).onSpaceUpdated();
+    }
+
+    @Test
+    public void
+            setOnSpaceUpdatedListener_multipleListeners_callsLastListenerOnNodeTransformEvent() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeScheduledExecutorService fakeExecutor = getDefaultFakeExecutor();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        Mat4f mat4f = new Mat4f(Matrix4.Identity.getData());
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        OnSpaceUpdatedListener listener2 = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, executor);
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener2, executor);
+
+        node.sendTransformEvent(nodeTransformEvent);
+        fakeExecutor.runAll();
+        assertThat(executor.hasNext()).isTrue();
+        executor.runAll();
+
+        verify(listener, Mockito.never()).onSpaceUpdated();
+        verify(listener2).onSpaceUpdated();
+    }
+
+    @Test
+    public void
+            setOnSpaceUpdatedListener_withNullExecutor_callsListenerOnNodeTransformEventExecutor() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        Mat4f mat4f = new Mat4f(Matrix4.Identity.getData());
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, null);
+
+        node.sendTransformEvent(nodeTransformEvent);
+        getDefaultFakeExecutor().runAll();
+
+        verify(listener).onSpaceUpdated();
+    }
+
+    @Test
+    public void setOnSpaceUpdatedListener_withNullListener_noListenerCalledOnNodeTransformEvent() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        FakeNode node = (FakeNode) systemSpaceEntity.getNode();
+        Mat4f mat4f = new Mat4f(Matrix4.Identity.getData());
+        FakeNodeTransform nodeTransformEvent = new FakeNodeTransform(mat4f);
+
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, null);
+        systemSpaceEntity.setOnSpaceUpdatedListener(null, null);
+
+        node.sendTransformEvent(nodeTransformEvent);
+        getDefaultFakeExecutor().runAll();
+
+        verify(listener, Mockito.never()).onSpaceUpdated();
+    }
+
+    @Test
+    public void dispose_disposesChildren() throws Exception {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        AndroidXrEntity childEntity = createChildAndroidXrEntity();
+
+        systemSpaceEntity.addChild(childEntity);
+
+        // Verify the parent of the child node is the space node before disposing it.
+        FakeNode systemSpaceNode = (FakeNode) systemSpaceEntity.getNode();
+        FakeNode childNode = (FakeNode) childEntity.getNode();
+        assertThat(childNode.getParent()).isEqualTo(systemSpaceNode);
+
+        // Dispose the space entity and verify that the children were disposed.
+        systemSpaceEntity.dispose();
+
+        assertThat(childNode.getParent()).isNull();
+    }
+
+    @Test
+    public void setPoseInOpenXrReferenceSpace_callsOnSpaceUpdated() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        OnSpaceUpdatedListener listener = Mockito.mock(OnSpaceUpdatedListener.class);
+        FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
+
+        systemSpaceEntity.setOnSpaceUpdatedListener(listener, executor);
+        systemSpaceEntity.setOpenXrReferenceSpacePose(Matrix4.Identity);
+        executor.runAll();
+
+        verify(listener).onSpaceUpdated();
+    }
+
+    @Test
+    public void setPoseInOpenXrReferenceSpace_updatesPose() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        Matrix4 matrix =
+                new Matrix4( // --               Column major, right handed 4x4 Transformation
+                        // Matrix with
+                        new float[] { // --         translation of (4, 8, 12) and rotation 90 (@)
+                            // around Z axis
+                            0f, 1f, 0f, 0f, // --     cos(@),   sin(@), 0,  0
+                            -1f, 0f, 0f, 0f, // --    -sin(@),  cos(@), 0,  0
+                            0f, 0f, 1f, 0f, // --     0,        0,      1,  0
+                            4f, 8f, 12f, 1f, // --    tx,       ty,     tz, 1
+                        });
+        Pose pose =
+                new Pose(
+                        new Vector3(4f, 8f, 12f),
+                        Quaternion.fromAxisAngle(new Vector3(0f, 0f, 1f), 90f));
+
+        systemSpaceEntity.setOpenXrReferenceSpacePose(matrix);
+        assertPose(systemSpaceEntity.getPoseInOpenXrReferenceSpace(), pose);
+    }
+
+    @Test
+    public void setPoseInOpenXrReferenceSpace_updatesScale() {
+        SystemSpaceEntityImpl systemSpaceEntity = getSystemSpaceEntityImpl();
+        Matrix4 matrix =
+                new Matrix4( // --               Column major, right handed 4x4 Transformation
+                        // Matrix with
+                        new float // --             translation of (4, 8, 12) and rotation 90 (@)
+                                // around Z axis,
+                                [] { // --              and scale of 3.3.
+                            0f, 3.3f, 0f, 0f, // --     cos(@),   sin(@), 0,  0
+                            -3.3f, 0f, 0f, 0f, // --    -sin(@),  cos(@), 0,  0
+                            0f, 0f, 3.3f, 0f, // --     0,        0,      1,  0
+                            4f, 8f, 12f, 1f, // --      tx,       ty,     tz, 1
+                        });
+        Vector3 scale = new Vector3(3.3f, 3.3f, 3.3f);
+
+        systemSpaceEntity.setOpenXrReferenceSpacePose(matrix);
+        assertVector3(
+                systemSpaceEntity.getActivitySpaceScale(),
+                scale.div(getActivitySpaceEntity().getWorldSpaceScale()));
+        assertVector3(systemSpaceEntity.getWorldSpaceScale(), scale);
+        assertVector3(systemSpaceEntity.getScale(), scale);
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.exr b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.exr
new file mode 100644
index 0000000..5d16938
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.exr
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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.
+ */
+
+TestImage.
\ No newline at end of file
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.glb b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.glb
new file mode 100644
index 0000000..ab5693d
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/fake_assets/FakeAsset.glb
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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.
+ */
+
+Test.
\ No newline at end of file
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/FovTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/FovTest.java
new file mode 100644
index 0000000..f9aaad2
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/FovTest.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class FovTest {
+
+    @Test
+    public void createFov_returnsFov() {
+        Fov fov = new Fov(1.0f, 2.0f, 3.0f, 4.0f);
+        assertThat(fov.getAngleLeft()).isEqualTo(1.0f);
+        assertThat(fov.getAngleRight()).isEqualTo(2.0f);
+        assertThat(fov.getAngleUp()).isEqualTo(3.0f);
+        assertThat(fov.getAngleDown()).isEqualTo(4.0f);
+    }
+
+    @Test
+    public void equals_returnsTrue() {
+        Fov fov1 = new Fov(1.0f, 2.0f, 3.0f, 4.0f);
+        Fov fov2 = new Fov(1.0f, 2.0f, 3.0f, 4.0f);
+        assertThat(fov1.equals(fov2)).isTrue();
+    }
+
+    @Test
+    public void equals_returnsFalse() {
+        Fov fov1 = new Fov(1.0f, 2.0f, 3.0f, 4.0f);
+        Fov fov2 = new Fov(1.0f, 2.0f, 3.0f, 5.0f);
+        assertThat(fov1.equals(fov2)).isFalse();
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryTestHelper.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryTestHelper.java
new file mode 100644
index 0000000..ff32567
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PerceptionLibraryTestHelper.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import android.os.IBinder;
+
+/**
+ * A library with an interface to native functions that are used to set up state for perception
+ * manager tests.
+ */
+final class PerceptionLibraryTestHelper {
+
+    public PerceptionLibraryTestHelper() {}
+
+    public native void reset();
+
+    public native void setCreateSessionResult(boolean success);
+
+    public native int getOpenXrSessionReferenceSpaceType();
+
+    public native void setGetAnchorResult(IBinder binder, long anchorId);
+
+    public native void setAnchorUuidBytes(byte[] anchorUuidBytes);
+
+    public native void setAnchorPersistState(int persistState);
+
+    public native void setGetCurrentHeadPoseResult(
+            float x, float y, float z, float qx, float qy, float qz, float qw);
+
+    public native void setLeftView(
+            float x,
+            float y,
+            float z,
+            float qx,
+            float qy,
+            float qz,
+            float qw,
+            float angleLeft,
+            float angleRight,
+            float angleUp,
+            float angleDown);
+
+    public native void setRightView(
+            float x,
+            float y,
+            float z,
+            float qx,
+            float qy,
+            float qz,
+            float qw,
+            float angleLeft,
+            float angleRight,
+            float angleUp,
+            float angleDown);
+
+    public native void addPlane(
+            long plane,
+            Pose centerPose,
+            float extentWidth,
+            float extentHeight,
+            int type,
+            int label);
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PoseTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PoseTest.java
new file mode 100644
index 0000000..700a0eb
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/PoseTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class PoseTest {
+
+    @Test
+    public void identityPose_returnsPose() {
+        Pose pose = Pose.identity();
+
+        assertThat(pose.tx()).isEqualTo(0);
+        assertThat(pose.ty()).isEqualTo(0);
+        assertThat(pose.tz()).isEqualTo(0);
+        assertThat(pose.qx()).isEqualTo(0);
+        assertThat(pose.qy()).isEqualTo(0);
+        assertThat(pose.qz()).isEqualTo(0);
+        assertThat(pose.qw()).isEqualTo(1);
+    }
+
+    @Test
+    public void createPose_returnsPose() {
+        Pose pose = new Pose(1, 2, 3, 4, 5, 6, 7);
+
+        assertThat(pose.tx()).isEqualTo(1);
+        assertThat(pose.ty()).isEqualTo(2);
+        assertThat(pose.tz()).isEqualTo(3);
+        assertThat(pose.qx()).isEqualTo(4);
+        assertThat(pose.qy()).isEqualTo(5);
+        assertThat(pose.qz()).isEqualTo(6);
+        assertThat(pose.qw()).isEqualTo(7);
+    }
+
+    @Test
+    public void updateTranslation_updatseTranslation() {
+        Pose pose = new Pose(1, 2, 3, 4, 5, 6, 7);
+
+        pose.updateTranslation(10, 20, 30);
+
+        assertThat(pose.tx()).isEqualTo(10);
+        assertThat(pose.ty()).isEqualTo(20);
+        assertThat(pose.tz()).isEqualTo(30);
+        assertThat(pose.qx()).isEqualTo(4);
+        assertThat(pose.qy()).isEqualTo(5);
+        assertThat(pose.qz()).isEqualTo(6);
+        assertThat(pose.qw()).isEqualTo(7);
+    }
+
+    @Test
+    public void updateRotation_updatesRotation() {
+        Pose pose = new Pose(1, 2, 3, 4, 5, 6, 7);
+
+        pose.updateRotation(40, 50, 60, 70);
+
+        assertThat(pose.tx()).isEqualTo(1);
+        assertThat(pose.ty()).isEqualTo(2);
+        assertThat(pose.tz()).isEqualTo(3);
+        assertThat(pose.qx()).isEqualTo(40);
+        assertThat(pose.qy()).isEqualTo(50);
+        assertThat(pose.qz()).isEqualTo(60);
+        assertThat(pose.qw()).isEqualTo(70);
+    }
+
+    @Test
+    public void equals_returnsTrue() {
+        Pose pose = new Pose(1, 2, 3, 4, 5, 6, 7);
+        assertThat(pose).isEqualTo(new Pose(1, 2, 3, 4, 5, 6, 7));
+    }
+}
diff --git a/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/ViewProjectionTest.java b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/ViewProjectionTest.java
new file mode 100644
index 0000000..7b39853
--- /dev/null
+++ b/xr/scenecore/scenecore/src/test/java/androidx/xr/scenecore/impl/perception/ViewProjectionTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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 androidx.xr.scenecore.impl.perception;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ViewProjectionTest {
+
+    @Test
+    public void createViewProjection_returnsViewProjection() {
+        ViewProjection viewProjection =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 11));
+
+        assertThat(viewProjection.getPose()).isEqualTo(new Pose(1, 2, 3, 4, 5, 6, 7));
+        assertThat(viewProjection.getFov()).isEqualTo(new Fov(8, 9, 10, 11));
+    }
+
+    @Test
+    public void equals_returnsTrue() {
+        ViewProjection viewProjection1 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 11));
+        ViewProjection viewProjection2 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 11));
+        assertThat(viewProjection1).isEqualTo(viewProjection2);
+    }
+
+    @Test
+    public void equals_returnsFalse_forDifferentFov() {
+        ViewProjection viewProjection1 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 11));
+        ViewProjection viewProjection2 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 13));
+        assertThat(viewProjection1).isNotEqualTo(viewProjection2);
+    }
+
+    @Test
+    public void equals_returnsFalse_forDifferentPose() {
+        ViewProjection viewProjection1 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 77), new Fov(8, 9, 10, 11));
+        ViewProjection viewProjection2 =
+                new ViewProjection(new Pose(1, 2, 3, 4, 5, 6, 7), new Fov(8, 9, 10, 11));
+        assertThat(viewProjection1).isNotEqualTo(viewProjection2);
+    }
+}
diff --git a/camera/camera-effects-still-portrait/api/current.txt b/xr/xr-stubs/api/current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/current.txt
copy to xr/xr-stubs/api/current.txt
diff --git a/camera/camera-effects-still-portrait/api/res-current.txt b/xr/xr-stubs/api/res-current.txt
similarity index 100%
copy from camera/camera-effects-still-portrait/api/res-current.txt
copy to xr/xr-stubs/api/res-current.txt
diff --git a/xr/xr-stubs/api/restricted_current.txt b/xr/xr-stubs/api/restricted_current.txt
new file mode 100644
index 0000000..33fa1a3
--- /dev/null
+++ b/xr/xr-stubs/api/restricted_current.txt
@@ -0,0 +1,476 @@
+// Signature format: 4.0
+package com.android.extensions.xr {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Config {
+    method public float defaultPixelsPerMeter(float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class XrExtensionResult {
+    method public int getResult();
+    field @Deprecated public static final int XR_RESULT_ERROR_IGNORED = 3; // 0x3
+    field @Deprecated public static final int XR_RESULT_ERROR_INVALID_STATE = 2; // 0x2
+    field public static final int XR_RESULT_ERROR_NOT_ALLOWED = 3; // 0x3
+    field public static final int XR_RESULT_ERROR_SYSTEM = 4; // 0x4
+    field public static final int XR_RESULT_IGNORED_ALREADY_APPLIED = 2; // 0x2
+    field public static final int XR_RESULT_SUCCESS = 0; // 0x0
+    field public static final int XR_RESULT_SUCCESS_NOT_VISIBLE = 1; // 0x1
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class XrExtensions {
+    ctor public XrExtensions();
+    method public void addFindableView(android.view.View!, android.view.ViewGroup!);
+    method @Deprecated public void attachSpatialEnvironment(android.app.Activity!, com.android.extensions.xr.node.Node!);
+    method public void attachSpatialEnvironment(android.app.Activity!, com.android.extensions.xr.node.Node!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public void attachSpatialScene(android.app.Activity!, com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.Node!);
+    method public void attachSpatialScene(android.app.Activity!, com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.Node!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public boolean canEmbedActivityPanel(android.app.Activity!);
+    method public void clearSpatialStateCallback(android.app.Activity!);
+    method public com.android.extensions.xr.space.ActivityPanel! createActivityPanel(android.app.Activity!, com.android.extensions.xr.space.ActivityPanelLaunchParameters!);
+    method public com.android.extensions.xr.node.Node! createNode();
+    method public com.android.extensions.xr.node.NodeTransaction! createNodeTransaction();
+    method public com.android.extensions.xr.node.ReformOptions! createReformOptions(com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent!>!, java.util.concurrent.Executor!);
+    method public com.android.extensions.xr.splitengine.SplitEngineBridge! createSplitEngineBridge();
+    method public com.android.extensions.xr.subspace.Subspace! createSubspace(com.android.extensions.xr.splitengine.SplitEngineBridge!, int);
+    method @Deprecated public void detachSpatialEnvironment(android.app.Activity!);
+    method public void detachSpatialEnvironment(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public void detachSpatialScene(android.app.Activity!);
+    method public void detachSpatialScene(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<com.android.extensions.xr.XrExtensions.SceneViewerResult!>! displayGltfModel(android.app.Activity!, com.android.extensions.xr.asset.GltfModelToken!);
+    method public int getApiVersion();
+    method @Deprecated public void getBounds(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.Bounds!>!, java.util.concurrent.Executor!);
+    method public com.android.extensions.xr.Config! getConfig();
+    method public int getOpenXrWorldReferenceSpaceType();
+    method @Deprecated public void getSpatialCapabilities(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.SpatialCapabilities!>!, java.util.concurrent.Executor!);
+    method public com.android.extensions.xr.space.SpatialState! getSpatialState(android.app.Activity!);
+    method public com.android.extensions.xr.node.Node! getSurfaceTrackingNode(android.view.View!);
+    method public com.android.extensions.xr.media.XrSpatialAudioExtensions! getXrSpatialAudioExtensions();
+    method public void hitTest(android.app.Activity!, com.android.extensions.xr.node.Vec3!, com.android.extensions.xr.node.Vec3!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.HitTestResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.EnvironmentToken!>! loadEnvironment(java.io.InputStream!, int, int, String!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.EnvironmentToken!>! loadEnvironment(java.io.InputStream!, int, int, String!, int, int);
+    method @Deprecated public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.GltfModelToken!>! loadGltfModel(java.io.InputStream!, int, int, String!);
+    method @Deprecated public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.SceneToken!>! loadImpressScene(java.io.InputStream!, int, int);
+    method public void removeFindableView(android.view.View!, android.view.ViewGroup!);
+    method @Deprecated public boolean requestFullSpaceMode(android.app.Activity!);
+    method public void requestFullSpaceMode(android.app.Activity!, boolean, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public boolean requestHomeSpaceMode(android.app.Activity!);
+    method public android.os.Bundle! setFullSpaceStartMode(android.os.Bundle!);
+    method public android.os.Bundle! setFullSpaceStartModeWithEnvironmentInherited(android.os.Bundle!);
+    method @Deprecated public android.os.Bundle! setMainPanelCurvatureRadius(android.os.Bundle!, float);
+    method @Deprecated public void setMainWindowCurvatureRadius(android.app.Activity!, float);
+    method @Deprecated public void setMainWindowSize(android.app.Activity!, int, int);
+    method public void setMainWindowSize(android.app.Activity!, int, int, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method @Deprecated public void setPreferredAspectRatio(android.app.Activity!, float);
+    method public void setPreferredAspectRatio(android.app.Activity!, float, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult!>!, java.util.concurrent.Executor!);
+    method public void setSpatialStateCallback(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.SpatialState!>!, java.util.concurrent.Executor!);
+    method @Deprecated public void setSpatialStateCallbackDeprecated(android.app.Activity!, com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.SpatialStateEvent!>!, java.util.concurrent.Executor!);
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class XrExtensions.SceneViewerResult {
+    ctor @Deprecated public XrExtensions.SceneViewerResult();
+  }
+
+}
+
+package com.android.extensions.xr.asset {
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface EnvironmentToken extends com.android.extensions.xr.asset.AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface GltfAnimation {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public enum GltfAnimation.State {
+    enum_constant @Deprecated public static final com.android.extensions.xr.asset.GltfAnimation.State LOOP;
+    enum_constant @Deprecated public static final com.android.extensions.xr.asset.GltfAnimation.State PLAY;
+    enum_constant @Deprecated public static final com.android.extensions.xr.asset.GltfAnimation.State STOP;
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface GltfModelToken extends com.android.extensions.xr.asset.AssetToken {
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SceneToken extends com.android.extensions.xr.asset.AssetToken {
+  }
+
+}
+
+package com.android.extensions.xr.environment {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class EnvironmentVisibilityState {
+    method public int getCurrentState();
+    field public static final int APP_VISIBLE = 2; // 0x2
+    field public static final int HOME_VISIBLE = 1; // 0x1
+    field public static final int INVISIBLE = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PassthroughVisibilityState {
+    method public int getCurrentState();
+    method public float getOpacity();
+    field public static final int APP = 2; // 0x2
+    field public static final int DISABLED = 0; // 0x0
+    field public static final int HOME = 1; // 0x1
+    field public static final int SYSTEM = 3; // 0x3
+  }
+
+}
+
+package com.android.extensions.xr.function {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.FunctionalInterface public interface Consumer<T> {
+    method public void accept(T!);
+  }
+
+}
+
+package com.android.extensions.xr.media {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class AudioManagerExtensions {
+    method public void playSoundEffectAsPointSource(android.media.AudioManager!, int, com.android.extensions.xr.media.PointSourceAttributes!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class AudioTrackExtensions {
+    method public com.android.extensions.xr.media.PointSourceAttributes! getPointSourceAttributes(android.media.AudioTrack!);
+    method public com.android.extensions.xr.media.SoundFieldAttributes! getSoundFieldAttributes(android.media.AudioTrack!);
+    method public int getSpatialSourceType(android.media.AudioTrack!);
+    method public android.media.AudioTrack.Builder! setPointSourceAttributes(android.media.AudioTrack.Builder!, com.android.extensions.xr.media.PointSourceAttributes!);
+    method public android.media.AudioTrack.Builder! setSoundFieldAttributes(android.media.AudioTrack.Builder!, com.android.extensions.xr.media.SoundFieldAttributes!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class MediaPlayerExtensions {
+    method public android.media.MediaPlayer! setPointSourceAttributes(android.media.MediaPlayer!, com.android.extensions.xr.media.PointSourceAttributes!);
+    method public android.media.MediaPlayer! setSoundFieldAttributes(android.media.MediaPlayer!, com.android.extensions.xr.media.SoundFieldAttributes!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PointSourceAttributes {
+    method public com.android.extensions.xr.node.Node! getNode();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final class PointSourceAttributes.Builder {
+    ctor public PointSourceAttributes.Builder();
+    method public com.android.extensions.xr.media.PointSourceAttributes! build() throws java.lang.UnsupportedOperationException;
+    method public com.android.extensions.xr.media.PointSourceAttributes.Builder! setNode(com.android.extensions.xr.node.Node!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SoundFieldAttributes {
+    method public int getAmbisonicsOrder();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final class SoundFieldAttributes.Builder {
+    ctor public SoundFieldAttributes.Builder();
+    method public com.android.extensions.xr.media.SoundFieldAttributes! build() throws java.lang.UnsupportedOperationException;
+    method public com.android.extensions.xr.media.SoundFieldAttributes.Builder! setAmbisonicsOrder(int);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SoundPoolExtensions {
+    method public int getSpatialSourceType(android.media.SoundPool!, int);
+    method public int playAsPointSource(android.media.SoundPool!, int, com.android.extensions.xr.media.PointSourceAttributes!, float, int, int, float);
+    method public int playAsSoundField(android.media.SoundPool!, int, com.android.extensions.xr.media.SoundFieldAttributes!, float, int, int, float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SpatializerExtensions {
+    field public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+    field public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+    field public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+    field public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+    field public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+    field public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class XrSpatialAudioExtensions {
+    method public com.android.extensions.xr.media.AudioManagerExtensions! getAudioManagerExtensions();
+    method public com.android.extensions.xr.media.AudioTrackExtensions! getAudioTrackExtensions();
+    method public com.android.extensions.xr.media.MediaPlayerExtensions! getMediaPlayerExtensions();
+    method public com.android.extensions.xr.media.SoundPoolExtensions! getSoundPoolExtensions();
+  }
+
+}
+
+package com.android.extensions.xr.node {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class InputEvent {
+    method public int getAction();
+    method public com.android.extensions.xr.node.Vec3! getDirection();
+    method public int getDispatchFlags();
+    method public com.android.extensions.xr.node.InputEvent.HitInfo! getHitInfo();
+    method public com.android.extensions.xr.node.Vec3! getOrigin();
+    method public int getPointerType();
+    method public com.android.extensions.xr.node.InputEvent.HitInfo! getSecondaryHitInfo();
+    method public int getSource();
+    method public long getTimestamp();
+    field public static final int ACTION_CANCEL = 3; // 0x3
+    field public static final int ACTION_DOWN = 0; // 0x0
+    field public static final int ACTION_HOVER_ENTER = 5; // 0x5
+    field public static final int ACTION_HOVER_EXIT = 6; // 0x6
+    field public static final int ACTION_HOVER_MOVE = 4; // 0x4
+    field public static final int ACTION_MOVE = 2; // 0x2
+    field public static final int ACTION_UP = 1; // 0x1
+    field public static final int DISPATCH_FLAG_2D = 2; // 0x2
+    field public static final int DISPATCH_FLAG_CAPTURED_POINTER = 1; // 0x1
+    field public static final int DISPATCH_FLAG_NONE = 0; // 0x0
+    field public static final int POINTER_TYPE_DEFAULT = 0; // 0x0
+    field public static final int POINTER_TYPE_LEFT = 1; // 0x1
+    field public static final int POINTER_TYPE_RIGHT = 2; // 0x2
+    field public static final int SOURCE_CONTROLLER = 2; // 0x2
+    field public static final int SOURCE_GAZE_AND_GESTURE = 5; // 0x5
+    field public static final int SOURCE_HANDS = 3; // 0x3
+    field public static final int SOURCE_HEAD = 1; // 0x1
+    field public static final int SOURCE_MOUSE = 4; // 0x4
+    field public static final int SOURCE_UNKNOWN = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static class InputEvent.HitInfo {
+    ctor public InputEvent.HitInfo(int, com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.Mat4f!, com.android.extensions.xr.node.Vec3!);
+    method public com.android.extensions.xr.node.Vec3! getHitPosition();
+    method public com.android.extensions.xr.node.Node! getInputNode();
+    method public int getSubspaceImpressNodeId();
+    method public com.android.extensions.xr.node.Mat4f! getTransform();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Mat4f {
+    ctor public Mat4f(float[]!);
+    method public float get(int, int);
+    method public float[]! getFlattenedMatrix();
+    method public void set(int, int, float);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Node implements android.os.Parcelable {
+    method public int describeContents();
+    method public void listenForInput(com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.InputEvent!>!, java.util.concurrent.Executor!);
+    method public void requestPointerCapture(com.android.extensions.xr.function.Consumer<java.lang.Integer!>!, java.util.concurrent.Executor!);
+    method public void setNonPointerFocusTarget(android.view.AttachedSurfaceControl!);
+    method public void stopListeningForInput();
+    method public void stopPointerCapture();
+    method public java.io.Closeable! subscribeToTransform(com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.NodeTransform!>!, java.util.concurrent.Executor!);
+    method public void writeToParcel(android.os.Parcel!, int);
+    field public static final int POINTER_CAPTURE_STATE_ACTIVE = 1; // 0x1
+    field public static final int POINTER_CAPTURE_STATE_PAUSED = 0; // 0x0
+    field public static final int POINTER_CAPTURE_STATE_STOPPED = 2; // 0x2
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class NodeTransaction implements java.io.Closeable {
+    method public void apply();
+    method public void close();
+    method public com.android.extensions.xr.node.NodeTransaction! disableReform(com.android.extensions.xr.node.Node!);
+    method public com.android.extensions.xr.node.NodeTransaction! enableReform(com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.ReformOptions!);
+    method public com.android.extensions.xr.node.NodeTransaction! merge(com.android.extensions.xr.node.NodeTransaction!);
+    method public com.android.extensions.xr.node.NodeTransaction! removeCornerRadius(com.android.extensions.xr.node.Node!);
+    method public com.android.extensions.xr.node.NodeTransaction! setAlpha(com.android.extensions.xr.node.Node!, float);
+    method public com.android.extensions.xr.node.NodeTransaction! setAnchorId(com.android.extensions.xr.node.Node!, android.os.IBinder!);
+    method public com.android.extensions.xr.node.NodeTransaction! setCornerRadius(com.android.extensions.xr.node.Node!, float);
+    method @Deprecated public com.android.extensions.xr.node.NodeTransaction! setCurvature(com.android.extensions.xr.node.Node!, float);
+    method @Deprecated public com.android.extensions.xr.node.NodeTransaction! setEnvironment(com.android.extensions.xr.node.Node!, com.android.extensions.xr.asset.EnvironmentToken!);
+    method @Deprecated public com.android.extensions.xr.node.NodeTransaction! setGltfAnimation(com.android.extensions.xr.node.Node!, String!, com.android.extensions.xr.asset.GltfAnimation.State!);
+    method @Deprecated public com.android.extensions.xr.node.NodeTransaction! setGltfModel(com.android.extensions.xr.node.Node!, com.android.extensions.xr.asset.GltfModelToken!);
+    method @Deprecated public com.android.extensions.xr.node.NodeTransaction! setImpressScene(com.android.extensions.xr.node.Node!, com.android.extensions.xr.asset.SceneToken!);
+    method public com.android.extensions.xr.node.NodeTransaction! setName(com.android.extensions.xr.node.Node!, String!);
+    method public com.android.extensions.xr.node.NodeTransaction! setOrientation(com.android.extensions.xr.node.Node!, float, float, float, float);
+    method public com.android.extensions.xr.node.NodeTransaction! setParent(com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.Node!);
+    method public com.android.extensions.xr.node.NodeTransaction! setPassthroughState(com.android.extensions.xr.node.Node!, float, int);
+    method public com.android.extensions.xr.node.NodeTransaction! setPixelPositioning(com.android.extensions.xr.node.Node!, int);
+    method public com.android.extensions.xr.node.NodeTransaction! setPixelResolution(com.android.extensions.xr.node.Node!, float);
+    method public com.android.extensions.xr.node.NodeTransaction! setPosition(com.android.extensions.xr.node.Node!, float, float, float);
+    method public com.android.extensions.xr.node.NodeTransaction! setReformSize(com.android.extensions.xr.node.Node!, com.android.extensions.xr.node.Vec3!);
+    method public com.android.extensions.xr.node.NodeTransaction! setScale(com.android.extensions.xr.node.Node!, float, float, float);
+    method public com.android.extensions.xr.node.NodeTransaction! setSubspace(com.android.extensions.xr.node.Node!, com.android.extensions.xr.subspace.Subspace!);
+    method public com.android.extensions.xr.node.NodeTransaction! setSurfaceControl(com.android.extensions.xr.node.Node!, android.view.SurfaceControl!);
+    method public com.android.extensions.xr.node.NodeTransaction! setSurfacePackage(com.android.extensions.xr.node.Node!, android.view.SurfaceControlViewHost.SurfacePackage!);
+    method public com.android.extensions.xr.node.NodeTransaction! setVisibility(com.android.extensions.xr.node.Node!, boolean);
+    method public com.android.extensions.xr.node.NodeTransaction! setWindowBounds(android.view.SurfaceControl!, int, int);
+    method public com.android.extensions.xr.node.NodeTransaction! setWindowBounds(android.view.SurfaceControlViewHost.SurfacePackage!, int, int);
+    field public static final int POSITION_FROM_PARENT_TOP_LEFT = 64; // 0x40
+    field public static final int X_POSITION_IN_PIXELS = 1; // 0x1
+    field public static final int Y_POSITION_IN_PIXELS = 2; // 0x2
+    field public static final int Z_POSITION_IN_PIXELS = 4; // 0x4
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class NodeTransform {
+    method public long getTimestamp();
+    method public com.android.extensions.xr.node.Mat4f! getTransform();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Quatf {
+    ctor public Quatf(float, float, float, float);
+    field public final float w;
+    field public final float x;
+    field public final float y;
+    field public final float z;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class ReformEvent {
+    method public com.android.extensions.xr.node.Vec3! getCurrentRayDirection();
+    method public com.android.extensions.xr.node.Vec3! getCurrentRayOrigin();
+    method public int getId();
+    method public com.android.extensions.xr.node.Vec3! getInitialRayDirection();
+    method public com.android.extensions.xr.node.Vec3! getInitialRayOrigin();
+    method public com.android.extensions.xr.node.Quatf! getProposedOrientation();
+    method public com.android.extensions.xr.node.Vec3! getProposedPosition();
+    method public com.android.extensions.xr.node.Vec3! getProposedScale();
+    method public com.android.extensions.xr.node.Vec3! getProposedSize();
+    method public int getState();
+    method public int getType();
+    field public static final int REFORM_STATE_END = 3; // 0x3
+    field public static final int REFORM_STATE_ONGOING = 2; // 0x2
+    field public static final int REFORM_STATE_START = 1; // 0x1
+    field public static final int REFORM_STATE_UNKNOWN = 0; // 0x0
+    field public static final int REFORM_TYPE_MOVE = 1; // 0x1
+    field public static final int REFORM_TYPE_RESIZE = 2; // 0x2
+    field public static final int REFORM_TYPE_UNKNOWN = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class ReformOptions {
+    method public com.android.extensions.xr.node.Vec3! getCurrentSize();
+    method public int getEnabledReform();
+    method public com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent!>! getEventCallback();
+    method public java.util.concurrent.Executor! getEventExecutor();
+    method public float getFixedAspectRatio();
+    method public int getFlags();
+    method public boolean getForceShowResizeOverlay();
+    method public com.android.extensions.xr.node.Vec3! getMaximumSize();
+    method public com.android.extensions.xr.node.Vec3! getMinimumSize();
+    method public int getScaleWithDistanceMode();
+    method public com.android.extensions.xr.node.ReformOptions! setCurrentSize(com.android.extensions.xr.node.Vec3!);
+    method public com.android.extensions.xr.node.ReformOptions! setEnabledReform(int);
+    method public com.android.extensions.xr.node.ReformOptions! setEventCallback(com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent!>!);
+    method public com.android.extensions.xr.node.ReformOptions! setEventExecutor(java.util.concurrent.Executor!);
+    method public com.android.extensions.xr.node.ReformOptions! setFixedAspectRatio(float);
+    method public com.android.extensions.xr.node.ReformOptions! setFlags(int);
+    method public com.android.extensions.xr.node.ReformOptions! setForceShowResizeOverlay(boolean);
+    method public com.android.extensions.xr.node.ReformOptions! setMaximumSize(com.android.extensions.xr.node.Vec3!);
+    method public com.android.extensions.xr.node.ReformOptions! setMinimumSize(com.android.extensions.xr.node.Vec3!);
+    method public com.android.extensions.xr.node.ReformOptions! setScaleWithDistanceMode(int);
+    field public static final int ALLOW_MOVE = 1; // 0x1
+    field public static final int ALLOW_RESIZE = 2; // 0x2
+    field public static final int FLAG_ALLOW_SYSTEM_MOVEMENT = 2; // 0x2
+    field public static final int FLAG_POSE_RELATIVE_TO_PARENT = 4; // 0x4
+    field public static final int FLAG_SCALE_WITH_DISTANCE = 1; // 0x1
+    field public static final int SCALE_WITH_DISTANCE_MODE_DEFAULT = 3; // 0x3
+    field public static final int SCALE_WITH_DISTANCE_MODE_DMM = 2; // 0x2
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class Vec3 {
+    ctor public Vec3(float, float, float);
+    field public final float x;
+    field public final float y;
+    field public final float z;
+  }
+
+}
+
+package com.android.extensions.xr.passthrough {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class PassthroughState {
+    field public static final int PASSTHROUGH_MODE_MAX = 1; // 0x1
+    field public static final int PASSTHROUGH_MODE_MIN = 2; // 0x2
+    field public static final int PASSTHROUGH_MODE_OFF = 0; // 0x0
+  }
+
+}
+
+package com.android.extensions.xr.space {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class ActivityPanel {
+    method public void delete();
+    method public com.android.extensions.xr.node.Node! getNode();
+    method public void launchActivity(android.content.Intent!, android.os.Bundle!);
+    method public void moveActivity(android.app.Activity!);
+    method public void setWindowBounds(android.graphics.Rect!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class ActivityPanelLaunchParameters {
+    ctor public ActivityPanelLaunchParameters(android.graphics.Rect!);
+    method public android.graphics.Rect! getWindowBounds();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Bounds {
+    ctor public Bounds(float, float, float);
+    method public float getDepth();
+    method public float getHeight();
+    method public float getWidth();
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class BoundsChangeEvent implements com.android.extensions.xr.space.SpatialStateEvent {
+    ctor @Deprecated public BoundsChangeEvent(com.android.extensions.xr.space.Bounds!);
+    method @Deprecated public com.android.extensions.xr.space.Bounds! getBounds();
+    method @Deprecated public float getDepth();
+    method @Deprecated public float getHeight();
+    method @Deprecated public float getWidth();
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EnvironmentControlChangeEvent implements com.android.extensions.xr.space.SpatialStateEvent {
+    ctor @Deprecated public EnvironmentControlChangeEvent(boolean);
+    method @Deprecated public boolean getEnvironmentControlAllowed();
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EnvironmentVisibilityChangeEvent implements com.android.extensions.xr.space.SpatialStateEvent {
+    ctor @Deprecated public EnvironmentVisibilityChangeEvent(int);
+    method @Deprecated public int getEnvironmentState();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class HitTestResult {
+    method public float getDistance();
+    method public com.android.extensions.xr.node.Vec3! getHitPosition();
+    method public com.android.extensions.xr.node.Vec3! getSurfaceNormal();
+    method public int getSurfaceType();
+    method public boolean getVirtualEnvironmentIsVisible();
+    field public static final int SURFACE_3D_OBJECT = 2; // 0x2
+    field public static final int SURFACE_PANEL = 1; // 0x1
+    field public static final int SURFACE_UNKNOWN = 0; // 0x0
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public static final class HitTestResult.Builder {
+    ctor public HitTestResult.Builder(float, com.android.extensions.xr.node.Vec3!, boolean, int);
+    method public com.android.extensions.xr.space.HitTestResult! build();
+    method public com.android.extensions.xr.space.HitTestResult.Builder! setSurfaceNormal(com.android.extensions.xr.node.Vec3!);
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SpatialCapabilities {
+    ctor public SpatialCapabilities();
+    method public boolean get(int);
+    field public static final int APP_ENVIRONMENTS_CAPABLE = 3; // 0x3
+    field public static final int PASSTHROUGH_CONTROL_CAPABLE = 2; // 0x2
+    field public static final int SPATIAL_3D_CONTENTS_CAPABLE = 1; // 0x1
+    field public static final int SPATIAL_ACTIVITY_EMBEDDING_CAPABLE = 5; // 0x5
+    field public static final int SPATIAL_AUDIO_CAPABLE = 4; // 0x4
+    field public static final int SPATIAL_UI_CAPABLE = 0; // 0x0
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class SpatialCapabilityChangeEvent implements com.android.extensions.xr.space.SpatialStateEvent {
+    ctor @Deprecated public SpatialCapabilityChangeEvent(com.android.extensions.xr.space.SpatialCapabilities!);
+    method @Deprecated public com.android.extensions.xr.space.SpatialCapabilities! getCurrentCapabilities();
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SpatialState {
+    method public com.android.extensions.xr.space.Bounds! getBounds();
+    method public com.android.extensions.xr.environment.EnvironmentVisibilityState! getEnvironmentVisibility();
+    method public android.util.Size! getMainWindowSize();
+    method public com.android.extensions.xr.environment.PassthroughVisibilityState! getPassthroughVisibility();
+    method public float getPreferredAspectRatio();
+    method public com.android.extensions.xr.space.SpatialCapabilities! getSpatialCapabilities();
+    method public boolean isActiveEnvironmentNode(com.android.extensions.xr.node.Node!);
+    method public boolean isActiveSceneNode(com.android.extensions.xr.node.Node!);
+    method public boolean isActiveWindowLeashNode(com.android.extensions.xr.node.Node!);
+    method public boolean isEnvironmentInherited();
+  }
+
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public interface SpatialStateEvent {
+  }
+
+}
+
+package com.android.extensions.xr.splitengine {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class SplitEngineBridge {
+    method public long getNativeHandle();
+  }
+
+}
+
+package com.android.extensions.xr.subspace {
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public class Subspace {
+  }
+
+}
+
diff --git a/xr/xr-stubs/build.gradle b/xr/xr-stubs/build.gradle
new file mode 100644
index 0000000..5300f9a
--- /dev/null
+++ b/xr/xr-stubs/build.gradle
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+}
+
+dependencies {
+    implementation("androidx.annotation:annotation:1.8.1")
+}
+
+android {
+    namespace = "com.android.extensions.xr"
+}
+
+androidx {
+    name = "Android XR Extensions Stub"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Stub implementation of the Android XR Extensions."
+    // TODO: b/379715750 - Remove this flag once the deprecated methods have been removed from the API.
+    failOnDeprecationWarnings = false
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+    // TODO: b/326456246
+    optOutJSpecify = true
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/Config.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/Config.java
new file mode 100644
index 0000000..c3a767e
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/Config.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 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.android.extensions.xr;
+
+import androidx.annotation.RestrictTo;
+
+/** XR configuration information. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Config {
+
+    Config() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Returns the default pixelsPerMeter value for 2D surfaces. See
+     * NodeTransaction.setPixelResolution() for the meaning of pixelsPerMeter.
+     *
+     * @param density The logical density of the display.
+     * @return The default pixelsPerMeter value.
+     */
+    public float defaultPixelsPerMeter(float density) {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensionResult.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensionResult.java
new file mode 100644
index 0000000..a34f008
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensionResult.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 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.android.extensions.xr;
+
+import androidx.annotation.RestrictTo;
+
+/** Represents a result of an asynchronous XR Extension call. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class XrExtensionResult {
+
+    XrExtensionResult() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the result. */
+    public int getResult() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @deprecated Renamed. Use XR_RESULT_ERROR_NOT_ALLOWED.
+     */
+    @Deprecated public static final int XR_RESULT_ERROR_IGNORED = 3; // 0x3
+
+    /**
+     * @deprecated Renamed. Use XR_RESULT_IGNORED_ALREADY_APPLIED.
+     */
+    @Deprecated public static final int XR_RESULT_ERROR_INVALID_STATE = 2; // 0x2
+
+    /**
+     * The asynchronous call has been rejected by the system service because the caller activity
+     * does not have the required capability.
+     */
+    public static final int XR_RESULT_ERROR_NOT_ALLOWED = 3; // 0x3
+
+    /**
+     * The asynchronous call cannot be sent to the system service, or the service cannot properly
+     * handle the request. This is not a recoverable error for the client. For example, this error
+     * is sent to the client when an asynchronous call attempt has failed with a RemoteException.
+     */
+    public static final int XR_RESULT_ERROR_SYSTEM = 4; // 0x4
+
+    /**
+     * The asynchronous call has been ignored by the system service because the caller activity is
+     * already in the requested state.
+     */
+    public static final int XR_RESULT_IGNORED_ALREADY_APPLIED = 2; // 0x2
+
+    /**
+     * The asynchronous call has been accepted by the system service, and an immediate state change
+     * is expected.
+     */
+    public static final int XR_RESULT_SUCCESS = 0; // 0x0
+
+    /**
+     * The asynchronous call has been accepted by the system service, but the caller activity's
+     * spatial state won't be changed until other condition(s) are met.
+     */
+    public static final int XR_RESULT_SUCCESS_NOT_VISIBLE = 1; // 0x1
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensions.java
new file mode 100644
index 0000000..24e5929
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/XrExtensions.java
@@ -0,0 +1,931 @@
+/*
+ * Copyright 2024 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.android.extensions.xr;
+
+import android.content.Intent;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * The main extensions class that creates or provides instances of various XR Extensions components.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class XrExtensions {
+
+    public XrExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Get the current version of the {@link com.android.extensions.xr.XrExtensions XrExtensions}
+     * API.
+     */
+    public int getApiVersion() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously creates a node that can host a 2D panel or 3D subspace.
+     *
+     * @return A {@link com.android.extensions.xr.node.Node Node}.
+     */
+    public com.android.extensions.xr.node.Node createNode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously creates a new transaction that can be used to update multiple {@link
+     * com.android.extensions.xr.node.Node Node}'s data and transformation in the 3D space.
+     *
+     * @return A {@link com.android.extensions.xr.node.NodeTransaction NodeTransaction} that can be
+     *     used to queue the updates and submit to backend at once.
+     */
+    public com.android.extensions.xr.node.NodeTransaction createNodeTransaction() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously creates a subspace.
+     *
+     * @param splitEngineBridge The splitEngineBridge.
+     * @param subspaceId The unique identifier of the subspace.
+     * @return A {@link com.android.extensions.xr.subspace.Subspace Subspace} that can be used to
+     *     render 3D content in.
+     */
+    public com.android.extensions.xr.subspace.Subspace createSubspace(
+            com.android.extensions.xr.splitengine.SplitEngineBridge splitEngineBridge,
+            int subspaceId) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Loads and caches the glTF model in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the glTF model.
+     * @param regionSizeBytes The size of the memory region where the model is stored (in bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded. This string is only used for caching purposes.
+     * @return A {@link java.util.concurrent.CompletableFuture CompletableFuture} that either
+     *     contains the {@link com.android.extensions.xr.asset.GltfModelToken GltfModelToken}
+     *     representing the loaded model or 'null' if the asset could not be loaded successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.GltfModelToken>
+            loadGltfModel(
+                    java.io.InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    java.lang.String url) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Views a 3D asset.
+     *
+     * @param activity The activity which relinquishes control in order to display the model..
+     * @param gltfModel The model to display.
+     * @return A {@link java.util.concurrent.CompletableFuture CompletableFuture} that notifies the
+     *     caller when the session has completed.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public java.util.concurrent.CompletableFuture<
+                    com.android.extensions.xr.XrExtensions.SceneViewerResult>
+            displayGltfModel(
+                    android.app.Activity activity,
+                    com.android.extensions.xr.asset.GltfModelToken gltfModel) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Loads and caches the environment in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the EXR or JPEG environment.
+     * @param regionSizeBytes The size of the memory region where the environment is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded. This string is only used for caching purposes.
+     * @return A {@link java.util.concurrent.CompletableFuture CompletableFuture} that either
+     *     contains the {@link com.android.extensions.xr.asset.EnvironmentToken EnvironmentToken}
+     *     representing the loaded environment or 'null' if the asset could not be loaded
+     *     successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.EnvironmentToken>
+            loadEnvironment(
+                    java.io.InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    java.lang.String url) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Loads and caches the environment in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the EXR or JPEG environment.
+     * @param regionSizeBytes The size of the memory region where the environment is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @param url The URL of the asset to be loaded.
+     * @param textureWidth The target width of the final texture which will be downsampled/upsampled
+     *     from the original image.
+     * @param textureHeight The target height of the final texture which will be
+     *     downsampled/upsampled from the original image.
+     * @return A {@link java.util.concurrent.CompletableFuture CompletableFuture} that either
+     *     contains the {@link com.android.extensions.xr.asset.EnvironmentToken EnvironmentToken}
+     *     representing the loaded environment or 'null' if the asset could not be loaded
+     *     successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.EnvironmentToken>
+            loadEnvironment(
+                    java.io.InputStream asset,
+                    int regionSizeBytes,
+                    int regionOffsetBytes,
+                    java.lang.String url,
+                    int textureWidth,
+                    int textureHeight) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Loads and caches the Impress scene in the SpaceFlinger.
+     *
+     * @param asset The input stream data of the textproto Impress scene.
+     * @param regionSizeBytes The size of the memory region where the Impress scene is stored (in
+     *     bytes).
+     * @param regionOffsetBytes The offset from the beginning of the memory region (in bytes).
+     * @return A {@link java.util.concurrent.CompletableFuture CompletableFuture} that either
+     *     contains the {@link com.android.extensions.xr.asset.SceneToken SceneToken} representing
+     *     the loaded Impress scene or 'null' if the asset could not be loaded successfully.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public java.util.concurrent.CompletableFuture<com.android.extensions.xr.asset.SceneToken>
+            loadImpressScene(
+                    java.io.InputStream asset, int regionSizeBytes, int regionOffsetBytes) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously returns a {@link com.android.extensions.xr.splitengine.SplitEngineBridge
+     * SplitEngineBridge}.
+     *
+     * @return A {@link com.android.extensions.xr.splitengine.SplitEngineBridge SplitEngineBridge}.
+     */
+    public com.android.extensions.xr.splitengine.SplitEngineBridge createSplitEngineBridge() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously returns the implementation of the {@link
+     * com.android.extensions.xr.media.XrSpatialAudioExtensions XrSpatialAudioExtensions} component.
+     *
+     * @return The {@link com.android.extensions.xr.media.XrSpatialAudioExtensions
+     *     XrSpatialAudioExtensions}.
+     */
+    public com.android.extensions.xr.media.XrSpatialAudioExtensions getXrSpatialAudioExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Attaches the given {@code sceneNode} as the presentation for the given {@code activity} in
+     * the space, and asks the system to attach the 2D content of the {@code activity} into the
+     * given {@code windowNode}.
+     *
+     * <p>The {@code sceneNode} will only be visible if the {@code activity} is visible as in a
+     * lifecycle state between {@link android.app.Activity#onStart() Activity#onStart()} and {@link
+     * android.app.Activity#onStop() Activity#onStop()} and is SPATIAL_UI_CAPABLE too.
+     *
+     * <p>One activity can only attach one scene node. When a new scene node is attached for the
+     * same {@code activity}, the previous one will be detached.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param sceneNode the node to show as the presentation of the {@code activity}.
+     * @param windowNode a leash node to allow the app to control the position and size of the
+     *     activity's main window.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void attachSpatialScene(
+            android.app.Activity activity,
+            com.android.extensions.xr.node.Node sceneNode,
+            com.android.extensions.xr.node.Node windowNode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Attaches the given {@code sceneNode} as the presentation for the given {@code activity} in
+     * the space, and asks the system to attach the 2D content of the {@code activity} into the
+     * given {@code windowNode}.
+     *
+     * <p>The {@code sceneNode} will only be visible if the {@code activity} is visible as in a
+     * lifecycle state between {@link android.app.Activity#onStart() Activity#onStart()} and {@link
+     * android.app.Activity#onStop() Activity#onStop()} and is SPATIAL_UI_CAPABLE too.
+     *
+     * <p>One activity can only attach one scene node. When a new scene node is attached for the
+     * same {@code activity}, the previous one will be detached.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param sceneNode the node to show as the presentation of the {@code activity}.
+     * @param windowNode a leash node to allow the app to control the position and size of the
+     *     activity's main window.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void attachSpatialScene(
+            android.app.Activity activity,
+            com.android.extensions.xr.node.Node sceneNode,
+            com.android.extensions.xr.node.Node windowNode,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Detaches the {@code sceneNode} that was previously attached for the {@code activity} via
+     * {@link #attachSpatialScene}.
+     *
+     * <p>When an {@link android.app.Activity Activity} is destroyed, it must call this method to
+     * detach the scene node that was attached for itself.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void detachSpatialScene(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Detaches the {@code sceneNode} that was previously attached for the {@code activity} via
+     * {@link #attachSpatialScene}.
+     *
+     * <p>When an {@link android.app.Activity Activity} is destroyed, it must call this method to
+     * detach the scene node that was attached for itself.
+     *
+     * @param activity the owner activity of the {@code sceneNode}.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void detachSpatialScene(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Resizes the main window of the given activity to the requested size.
+     *
+     * @param activity the activity whose main window should be resized.
+     * @param width the new main window width in pixels.
+     * @param height the new main window height in pixels.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void setMainWindowSize(android.app.Activity activity, int width, int height) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Resizes the main window of the given activity to the requested size.
+     *
+     * @param activity the activity whose main window should be resized.
+     * @param width the new main window width in pixels.
+     * @param height the new main window height in pixels.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     SPATIAL_UI_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The request has
+     *     been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void setMainWindowSize(
+            android.app.Activity activity,
+            int width,
+            int height,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the main window of the given activity to the curvature radius. Note that it's allowed
+     * only for the activity in full space mode.
+     *
+     * @param activity the activity of the main window to which the curvature should be applied.
+     * @param curvatureRadius the panel curvature radius. It is measured in "radius * 1 /
+     *     curvature". A value of 0.0f means that the panel will be flat.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    public void setMainWindowCurvatureRadius(android.app.Activity activity, float curvatureRadius) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Attaches an environment node for a given activity to make it visible.
+     *
+     * <p>SysUI will attach the environment node to the task node when the activity gains the
+     * APP_ENVIRONMENTS_CAPABLE capability.
+     *
+     * <p>This method can be called multiple times, SysUI will attach the new environment node and
+     * detach the old environment node if it exists.
+     *
+     * @param activity the activity that provides the environment node to attach.
+     * @param environmentNode the environment node provided by the activity to be attached.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void attachSpatialEnvironment(
+            android.app.Activity activity, com.android.extensions.xr.node.Node environmentNode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Attaches an environment node for a given activity to make it visible.
+     *
+     * <p>SysUI will attach the environment node to the task node when the activity gains the
+     * APP_ENVIRONMENTS_CAPABLE capability.
+     *
+     * <p>This method can be called multiple times, SysUI will attach the new environment node and
+     * detach the old environment node if it exists.
+     *
+     * @param activity the activity that provides the environment node to attach.
+     * @param environmentNode the environment node provided by the activity to be attached.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     APP_ENVIRONMENTS_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void attachSpatialEnvironment(
+            android.app.Activity activity,
+            com.android.extensions.xr.node.Node environmentNode,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Detaches the environment node and its sub tree for a given activity to make it invisible.
+     *
+     * <p>This method will detach and cleanup the environment node and its subtree passed from the
+     * activity.
+     *
+     * @param activity the activity with which SysUI will detach and clean up the environment node
+     *     tree.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void detachSpatialEnvironment(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Detaches the environment node and its sub tree for a given activity to make it invisible.
+     *
+     * <p>This method will detach and cleanup the environment node and its subtree passed from the
+     * activity.
+     *
+     * @param activity the activity with which SysUI will detach and clean up the environment node
+     *     tree.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity becomes
+     *     APP_ENVIRONMENTS_CAPABLE. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void detachSpatialEnvironment(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets a callback to receive {@link com.android.extensions.xr.space.SpatialStateEvent
+     * SpatialStateEvent} for the given {@code activity}.
+     *
+     * <p>One activity can only set one callback. When a new callback is set for the same {@code
+     * activity}, the previous one will be cleared.
+     *
+     * <p>The callback will be triggered immediately with the current state when it is set, for each
+     * of the possible events.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     * @param callback the callback to set.
+     * @param executor the executor that the callback will be called on.
+     * @see #clearSpatialStateCallback
+     * @deprecated Use registerSpatialStateCallback instead.
+     */
+    @Deprecated
+    public void setSpatialStateCallbackDeprecated(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<
+                            com.android.extensions.xr.space.SpatialStateEvent>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously registers a callback to receive {@link android.view.xr.SpatialState
+     * SpatialState} for the {@code activity}.
+     *
+     * <p>One activity can only set one callback. When a new callback is set for the same {@code
+     * activity}, the previous one will be cleared.
+     *
+     * <p>The {@code executor}'s execute() method will soon be called to run the callback with the
+     * current state when it is available, but it never happens directly from within this call.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     * @param callback the callback to set.
+     * @param executor the executor that the callback will be called on.
+     * @see #clearSpatialStateCallback
+     */
+    public void setSpatialStateCallback(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<
+                            com.android.extensions.xr.space.SpatialState>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously clears the {@link com.android.extensions.xr.space.SpatialStateEvent
+     * SpatialStateEvent} callback that was previously set to the {@code activity} via {@link
+     * #setSpatialStateCallback}.
+     *
+     * <p>When an {@link android.app.Activity Activity} is destroyed, it must call this method to
+     * clear the callback that was set for itself.
+     *
+     * @param activity the activity for the {@code callback} to listen to.
+     */
+    public void clearSpatialStateCallback(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously creates an {@link com.android.extensions.xr.space.ActivityPanel ActivityPanel}
+     * to be embedded inside the given {@code host} activity.
+     *
+     * <p>Caller must make sure the {@code host} can embed {@link
+     * com.android.extensions.xr.space.ActivityPanel ActivityPanel}. See {@link getSpatialState}.
+     * When embedding is possible, SpatialState's {@link
+     * com.android.extensions.xr.space.SpatialCapabilities SpatialCapabilities} has {@code
+     * SPATIAL_ACTIVITY_EMBEDDING_CAPABLE}.
+     *
+     * <p>For the {@link com.android.extensions.xr.space.ActivityPanel ActivityPanel} to be shown in
+     * the scene, caller needs to attach the {@link
+     * com.android.extensions.xr.space.ActivityPanel#getNode() ActivityPanel#getNode()} to the scene
+     * node attached through {@link #attachSpatialScene}.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity.
+     *
+     * @param host the host activity to embed the {@link
+     *     com.android.extensions.xr.space.ActivityPanel ActivityPanel}.
+     * @param launchParameters the parameters to define the initial state of the {@link
+     *     com.android.extensions.xr.space.ActivityPanel ActivityPanel}.
+     * @return the {@link com.android.extensions.xr.space.ActivityPanel ActivityPanel} created.
+     * @throws java.lang.IllegalStateException if the {@code host} is not allowed to embed {@link
+     *     com.android.extensions.xr.space.ActivityPanel ActivityPanel}.
+     */
+    public com.android.extensions.xr.space.ActivityPanel createActivityPanel(
+            android.app.Activity host,
+            com.android.extensions.xr.space.ActivityPanelLaunchParameters launchParameters) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously checks if an activity can be the host to embed an {@link
+     * com.android.extensions.xr.space.ActivityPanel ActivityPanel}.
+     *
+     * <p>Activity inside an {@link com.android.extensions.xr.space.ActivityPanel ActivityPanel}
+     * cannot be the host.
+     *
+     * @param activity the activity to check.
+     * @see #createActivityPanel
+     * @return true if the embedding is allowed.
+     * @deprecated Use {@link getSpatialState} instead. When embedding is possible, SpatialState's
+     *     {@link com.android.extensions.xr.space.SpatialCapabilities SpatialCapabilities} has
+     *     {@code SPATIAL_ACTIVITY_EMBEDDING_CAPABLE}.
+     */
+    @Deprecated
+    public boolean canEmbedActivityPanel(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Requests to put an activity in full space mode when it has focus.
+     *
+     * @param activity the activity that requires to enter full space mode.
+     * @return true when the request was sent (when the activity has focus).
+     * @deprecated Use requestFullSpaceMode with 3 arguments.
+     */
+    @Deprecated
+    public boolean requestFullSpaceMode(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Requests to put an activity in home space mode when it has focus.
+     *
+     * @param activity the activity that requires to enter home space mode.
+     * @return true when the request was sent (when the activity has focus).
+     * @deprecated Use requestFullSpaceMode with 3 arguments.
+     */
+    @Deprecated
+    public boolean requestHomeSpaceMode(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Requests to put an activity in a different mode when it has focus.
+     *
+     * @param activity the activity that requires to enter full space mode.
+     * @param requestEnter when true, activity is put in full space mode. Home space mode otherwise.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested mode.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. not the top activity in a top task
+     *     in the desktop, called by an embedded guest activity.)
+     *     XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error has
+     *     happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void requestFullSpaceMode(
+            android.app.Activity activity,
+            boolean requestEnter,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously sets the full space mode flag to the given {@link android.os.Bundle Bundle}.
+     *
+     * <p>The {@link android.os.Bundle Bundle} then could be used to launch an {@link
+     * android.app.Activity Activity} with requesting to enter full space mode through {@link
+     * android.app.Activity#startActivity Activity#startActivity}. If there's a bundle used for
+     * customizing how the {@link android.app.Activity Activity} should be started by {@link
+     * ActivityOptions.toBundle} or {@link androidx.core.app.ActivityOptionsCompat.toBundle}, it's
+     * suggested to use the bundle to call this method.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * bundle, or it is not started from a focused Activity context.
+     *
+     * @param bundle the input bundle to set with the full space mode flag.
+     * @return the input {@code bundle} with the full space mode flag set.
+     */
+    public android.os.Bundle setFullSpaceStartMode(android.os.Bundle bundle) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously sets the inherit full space mode environvment flag to the given {@link
+     * android.os.Bundle Bundle}.
+     *
+     * <p>The {@link android.os.Bundle Bundle} then could be used to launch an {@link
+     * android.app.Activity Activity} with requesting to enter full space mode while inherit the
+     * existing environment through {@link android.app.Activity#startActivity
+     * Activity#startActivity}. If there's a bundle used for customizing how the {@link
+     * android.app.Activity Activity} should be started by {@link ActivityOptions.toBundle} or
+     * {@link androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to
+     * call this method.
+     *
+     * <p>When launched, the activity will be in full space mode and also inherits the environment
+     * from the launching activity. If the inherited environment needs to be animated, the launching
+     * activity has to continue updating the environment even after the activity is put into the
+     * stopped state.
+     *
+     * <p>The flag will be ignored when no {@link Intent.FLAG_ACTIVITY_NEW_TASK} is set in the
+     * intent, or it is not started from a focused Activity context.
+     *
+     * <p>The flag will also be ignored when there is no environment to inherit or the activity has
+     * its own environment set already.
+     *
+     * <p>For security reasons, Z testing for the new activity is disabled, and the activity is
+     * always drawn on top of the inherited environment. Because Z testing is disabled, the activity
+     * should not spatialize itself, and should not curve its panel too much either.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @return the input {@code bundle} with the inherit full space mode flag set.
+     */
+    public android.os.Bundle setFullSpaceStartModeWithEnvironmentInherited(
+            android.os.Bundle bundle) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets panel curvature radius to the given {@link android.os.Bundle Bundle}.
+     *
+     * <p>The {@link android.os.Bundle Bundle} then could be used to launch an {@link
+     * android.app.Activity Activity} with requesting to a custom curvature radius for the main
+     * panel through {@link android.app.Activity#startActivity Activity#startActivity}. If there's a
+     * bundle used for customizing how the {@link android.app.Activity Activity} should be started
+     * by {@link ActivityOptions.toBundle} or {@link
+     * androidx.core.app.ActivityOptionsCompat.toBundle}, it's suggested to use the bundle to call
+     * this method.
+     *
+     * <p>The curvature radius must be used together with {@link
+     * #setFullSpaceModeWithEnvironmentInherited(android.os.Bundle)}. Otherwise, it will be ignored.
+     *
+     * @param bundle the input bundle to set with the inherit full space mode environment flag.
+     * @param panelCurvatureRadius the panel curvature radius. It is measured in "radius * 1 /
+     *     curvature". A value of 0.0f means the panel is flat.
+     * @return the input {@code bundle} with the inherit full space mode flag set.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    public android.os.Bundle setMainPanelCurvatureRadius(
+            android.os.Bundle bundle, float panelCurvatureRadius) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously returns system config information.
+     *
+     * @return A {@link com.android.extensions.xr.Config Config} object.
+     */
+    public com.android.extensions.xr.Config getConfig() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Hit-tests a ray against the virtual scene. If the ray hits an object in the scene,
+     * information about the hit will be passed to the callback. If nothing is hit, the hit distance
+     * will be infinite. Note that attachSpatialScene() must be called before calling this method.
+     * Otherwise, an IllegalArgumentException is thrown.
+     *
+     * @param activity the requesting activity.
+     * @param origin the origin of the ray to test, in the activity's task coordinates.
+     * @param direction the direction of the ray to test, in the activity's task coordinates.
+     * @param callback the callback that will be called with the hit test result.
+     * @param executor the executor the callback will be called on.
+     */
+    public void hitTest(
+            android.app.Activity activity,
+            com.android.extensions.xr.node.Vec3 origin,
+            com.android.extensions.xr.node.Vec3 direction,
+            com.android.extensions.xr.function.Consumer<
+                            com.android.extensions.xr.space.HitTestResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously returns the OpenXR reference space type.
+     *
+     * @return the OpenXR reference space type used as world space for the shared scene.
+     * @see <a href="https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#spaces-reference-spaces">
+     *     OpenXR specs</a>
+     */
+    public int getOpenXrWorldReferenceSpaceType() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously creates a new ReformOptions instance.
+     *
+     * @param callback the callback that will be called with reform events.
+     * @param executor the executor the callback will be called on.
+     * @return the new builder instance.
+     */
+    public com.android.extensions.xr.node.ReformOptions createReformOptions(
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously makes a View findable via findViewById().
+     *
+     * <p>This is done without it being a child of the given group.
+     *
+     * @param view the view to add as findable.
+     * @param group a group that is part of the hierarchy that findViewById() will be called on.
+     */
+    public void addFindableView(android.view.View view, android.view.ViewGroup group) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously removes a findable view from the given group.
+     *
+     * @param view the view to remove as findable.
+     * @param group the group to remove the findable view from.
+     */
+    public void removeFindableView(android.view.View view, android.view.ViewGroup group) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Returns the surface tracking node for a view, if there is one.
+     *
+     * <p>The surface tracking node is centered on the Surface that the view is attached to, and is
+     * sized to match the surface's size. Note that the view's position in the surface can be
+     * retrieved via View.getLocationInSurface().
+     *
+     * @param view the view.
+     * @return the surface tracking node, or null if no such node exists.
+     */
+    public com.android.extensions.xr.node.Node getSurfaceTrackingNode(android.view.View view) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets a preferred main panel aspect ratio for home space mode.
+     *
+     * <p>The ratio is only applied to the activity. If the activity launches another activity in
+     * the same task, the ratio is not applied to the new activity. Also, while the activity is in
+     * full space mode, the preference is temporarily removed.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *     height. A value <= 0.0f means there are no preferences.
+     * @deprecated Use the new interface with a callback.
+     */
+    @Deprecated
+    public void setPreferredAspectRatio(android.app.Activity activity, float preferredRatio) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets a preferred main panel aspect ratio for an activity that is not SPATIAL_UI_CAPABLE.
+     *
+     * <p>The ratio is only applied to the activity. If the activity launches another activity in
+     * the same task, the ratio is not applied to the new activity. Also, while the activity is
+     * SPATIAL_UI_CAPABLE, the preference is temporarily removed. While the activity is
+     * SPATIAL_UI_CAPABLE, use ReformOptions API instead.
+     *
+     * @param activity the activity to set the preference.
+     * @param preferredRatio the aspect ratio determined by taking the panel's width over its
+     *     height. A value <= 0.0f means there are no preferences.
+     * @param callback the callback that will be called with the result. XrExtensionResult will
+     *     indicate either of the following: XrExtensionResult.XR_RESULT_SUCCESS: The request has
+     *     been accepted, and the client can expect that a spatial state callback with an updated
+     *     SpatialState will run shortly. XrExtensionResult.XR_RESULT_SUCCESS_NOT_VISIBLE: The
+     *     request has been accepted, but will not immediately change the spatial state. A spatial
+     *     state callback with an updated SpatialState won't run until the activity loses the
+     *     SPATIAL_UI_CAPABLE capability. XrExtensionResult.XR_RESULT_IGNORED_ALREADY_APPLIED: The
+     *     request has been ignored because the activity is already in the requested state.
+     *     XrExtensionResult.XR_RESULT_ERROR_NOT_ALLOWED: The request has been rejected because the
+     *     activity does not have the required capability (e.g. called by an embedded guest
+     *     activity.) XrExtensionResult.XR_RESULT_ERROR_SYSTEM: A unrecoverable service side error
+     *     has happened.
+     * @param executor the executor the callback will be called on.
+     */
+    public void setPreferredAspectRatio(
+            android.app.Activity activity,
+            float preferredRatio,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.XrExtensionResult>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the spatial capabilities of the activity.
+     *
+     * @param activity the activity to get the capabilities.
+     * @param callback the callback to run. If the activity is not found in SysUI, the callback runs
+     *     with a null SpatialCapabilities.
+     * @param executor the executor that the callback will be called on.
+     * @deprecated Use getSpatialState synchronous getter.
+     */
+    @Deprecated
+    public void getSpatialCapabilities(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<
+                            com.android.extensions.xr.space.SpatialCapabilities>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the bounds of the activity.
+     *
+     * @param activity the activity to get the bounds.
+     * @param callback the callback to run. If the activity is not found in SysUI, the callback runs
+     *     with a null Bounds.
+     * @param executor the executor that the callback will be called on.
+     * @deprecated Use getSpatialState synchronous getter.
+     */
+    @Deprecated
+    public void getBounds(
+            android.app.Activity activity,
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.space.Bounds>
+                    callback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Synchronously gets the spatial state of the activity.
+     *
+     * <p>Do not call the API from the Binder thread. That may cause a deadlock.
+     *
+     * <p>This API throws IllegalArgumentException if it is called by an embedded (guest) activity,
+     * and also throws RuntimeException if the calling thread is interrupted.
+     *
+     * @param activity the activity to get the capabilities.
+     * @return the state of the activity.
+     */
+    public com.android.extensions.xr.space.SpatialState getSpatialState(
+            android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * The result of a displayGltfModel request.
+     *
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @Deprecated
+    public class SceneViewerResult {
+
+        @Deprecated
+        public SceneViewerResult() {
+            throw new RuntimeException("Stub!");
+        }
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/AssetToken.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/AssetToken.java
new file mode 100644
index 0000000..d45c181
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/AssetToken.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a spatial asset cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link com.android.extensions.xr.asset.AssetToken AssetToken} such
+ * that the SpaceFlinger will render the asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link com.android.extensions.xr.asset.AssetToken
+ * AssetToken} so that it can continue using it, and eventually, free it when it is no longer
+ * needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface AssetToken {}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/EnvironmentToken.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/EnvironmentToken.java
new file mode 100644
index 0000000..e289a67
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/EnvironmentToken.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of an environment cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link com.android.extensions.xr.asset.EnvironmentToken
+ * EnvironmentToken} such that the SpaceFlinger will render the asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link
+ * com.android.extensions.xr.asset.EnvironmentToken EnvironmentToken} so that it can continue using
+ * it, and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface EnvironmentToken extends com.android.extensions.xr.asset.AssetToken {}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfAnimation.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfAnimation.java
new file mode 100644
index 0000000..238487f
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfAnimation.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Animation configuration to be played on a glTF model.
+ *
+ * @deprecated SceneCore doesn't need this anymore as it does the same with Split Engine.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface GltfAnimation {
+    /**
+     * State of a glTF animation.
+     *
+     * @deprecated No longer needed by SceneCore.
+     */
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    @Deprecated
+    enum State {
+        /**
+         * Will stop the glTF animation that is currently playing or looping.
+         *
+         * @deprecated No longer needed by SceneCore.
+         */
+        @Deprecated
+        STOP,
+        /**
+         * Will restart the glTF animation if it's currently playing, looping, or is stopped.
+         *
+         * @deprecated No longer needed by SceneCore.
+         */
+        @Deprecated
+        PLAY,
+        /**
+         * Will restart and loop the glTF animation if it's currently playing, looping, or is
+         * stopped.
+         *
+         * @deprecated No longer needed by SceneCore.
+         */
+        @Deprecated
+        LOOP;
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfModelToken.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfModelToken.java
new file mode 100644
index 0000000..0b8f80e
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/GltfModelToken.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a glTF model cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link com.android.extensions.xr.asset.GltfModelToken GltfModelToken}
+ * such that the SpaceFlinger will render the asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link
+ * com.android.extensions.xr.asset.GltfModelToken GltfModelToken} so that it can continue using it,
+ * and eventually, free it when it is no longer needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface GltfModelToken extends com.android.extensions.xr.asset.AssetToken {}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/SceneToken.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/SceneToken.java
new file mode 100644
index 0000000..c424750
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/asset/SceneToken.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.asset;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Token of a scene cached in the SpaceFlinger.
+ *
+ * <p>A Node can reference an {@link com.android.extensions.xr.asset.SceneToken SceneToken} such
+ * that the SpaceFlinger will render the asset inside it.
+ *
+ * <p>Note that the app needs to keep track of the {@link com.android.extensions.xr.asset.SceneToken
+ * SceneToken} so that it can continue using it, and eventually, free it when it is no longer
+ * needed.
+ *
+ * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface SceneToken extends com.android.extensions.xr.asset.AssetToken {}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/EnvironmentVisibilityState.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/EnvironmentVisibilityState.java
new file mode 100644
index 0000000..cc42a07
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/EnvironmentVisibilityState.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.environment;
+
+import androidx.annotation.RestrictTo;
+
+/** Visibility states of an environment. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class EnvironmentVisibilityState {
+
+    EnvironmentVisibilityState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current environment visibility state */
+    public int getCurrentState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** App environment is shown. Passthrough might be on but its opacity is less than 100%. */
+    public static final int APP_VISIBLE = 2; // 0x2
+
+    /** Home environment is shown. Passthrough might be on but its opacity is less than 100%. */
+    public static final int HOME_VISIBLE = 1; // 0x1
+
+    /**
+     * No environment is shown. This could mean Passthrough is on with 100% opacity or the
+     * home environment app has crashed.
+     */
+    public static final int INVISIBLE = 0; // 0x0
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/PassthroughVisibilityState.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/PassthroughVisibilityState.java
new file mode 100644
index 0000000..aa48496
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/environment/PassthroughVisibilityState.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.environment;
+
+import androidx.annotation.RestrictTo;
+
+/** Visibility states of passthrough. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PassthroughVisibilityState {
+
+    PassthroughVisibilityState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current passthrough visibility state */
+    public int getCurrentState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current passthrough opacity */
+    public float getOpacity() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** App set Passthrough is shown with greater than 0 opacity. */
+    public static final int APP = 2; // 0x2
+
+    /** Passthrough is not shown. */
+    public static final int DISABLED = 0; // 0x0
+
+    /** Home environment Passthrough is shown with greater than 0 opacity. */
+    public static final int HOME = 1; // 0x1
+
+    /** System set Passthrough is shown with greater than 0 opacity. */
+    public static final int SYSTEM = 3; // 0x3
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/function/Consumer.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/function/Consumer.java
new file mode 100644
index 0000000..56a4e5442
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/function/Consumer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.function;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Represents a function that accepts an argument and produces no result. It is used internally to
+ * avoid using Java 8 functional interface that leads to desugaring and Proguard shrinking.
+ *
+ * @param <T> the type of the input of the function
+ * @see java.util.function.Consumer
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
[email protected]
+public interface Consumer<T> {
+
+    /**
+     * Performs the operation on the given argument
+     *
+     * @param t the input argument
+     */
+    void accept(T t);
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioManagerExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioManagerExtensions.java
new file mode 100644
index 0000000..f980590
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioManagerExtensions.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Provides spatial audio extensions on the framework {@link
+ * com.android.extensions.xr.media.AudioManagerExtensions AudioManagerExtensions} class.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AudioManagerExtensions {
+
+    AudioManagerExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Play a spatialized sound effect for sound sources that will be rendered in 3D space as a
+     * point source.
+     *
+     * @param audioManager The {@link android.media.AudioManager AudioManager} to use to play the
+     *     sound effect.
+     * @param effectType The type of sound effect.
+     * @param attributes attributes to specify sound source in 3D. {@link
+     *     com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}.
+     */
+    public void playSoundEffectAsPointSource(
+            android.media.AudioManager audioManager,
+            int effectType,
+            com.android.extensions.xr.media.PointSourceAttributes attributes) {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioTrackExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioTrackExtensions.java
new file mode 100644
index 0000000..b8e6aaa
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/AudioTrackExtensions.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Provides spatial audio extensions on the framework {@link android.media.AudioTrack AudioTrack}
+ * class.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class AudioTrackExtensions {
+
+    AudioTrackExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the {@link com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}
+     * of the provided {@link android.media.AudioTrack AudioTrack}.
+     *
+     * @param track The {@link android.media.AudioTrack AudioTrack} from which to get the {@link
+     *     com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}.
+     * @return The {@link com.android.extensions.xr.media.PointSourceAttributes
+     *     PointSourceAttributes} of the provided track, null if not set.
+     */
+    public com.android.extensions.xr.media.PointSourceAttributes getPointSourceAttributes(
+            android.media.AudioTrack track) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the {@link com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes} of
+     * the provided {@link android.media.AudioTrack AudioTrack}.
+     *
+     * @param track The {@link android.media.AudioTrack AudioTrack} from which to get the {@link
+     *     com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes}.
+     * @return The {@link com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes}
+     *     of the provided track, null if not set.
+     */
+    public com.android.extensions.xr.media.SoundFieldAttributes getSoundFieldAttributes(
+            android.media.AudioTrack track) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the {@link SourceType} of the provided {@link android.media.AudioTrack AudioTrack}. This
+     * value is implicitly set depending one which type of attributes was used to configure the
+     * builder. Will return {@link SOURCE_TYPE_BYPASS} for tracks that didn't use spatial audio
+     * attributes.
+     *
+     * @param track The {@link android.media.AudioTrack AudioTrack} from which to get the {@link
+     *     SourceType}.
+     * @return The {@link SourceType} of the provided track.
+     */
+    public int getSpatialSourceType(android.media.AudioTrack track) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the {@link com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}
+     * on the provided {@link android.media.AudioTrack.Builder AudioTrack.Builder}.
+     *
+     * @param builder The Builder on which to set the attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {AudioTrack.Builder} instance provided.
+     */
+    public android.media.AudioTrack.Builder setPointSourceAttributes(
+            android.media.AudioTrack.Builder builder,
+            com.android.extensions.xr.media.PointSourceAttributes attributes) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the {@link com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes} on
+     * the provided {@link android.media.AudioTrack.Builder AudioTrack.Builder}.
+     *
+     * @param builder The Builder on which to set the attributes.
+     * @param attributes The sound field attributes to be set.
+     * @return The same {AudioTrack.Builder} instance provided.
+     */
+    public android.media.AudioTrack.Builder setSoundFieldAttributes(
+            android.media.AudioTrack.Builder builder,
+            com.android.extensions.xr.media.SoundFieldAttributes attributes) {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/MediaPlayerExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/MediaPlayerExtensions.java
new file mode 100644
index 0000000..52bb573
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/MediaPlayerExtensions.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Provides spatial audio extensions on the framework {@link android.media.MediaPlayer MediaPlayer}
+ * class.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class MediaPlayerExtensions {
+
+    MediaPlayerExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @param mediaPlayer The {@link android.media.MediaPlayer MediaPlayer} on which to set the
+     *     attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {@link android.media.MediaPlayer MediaPlayer} instance provided.
+     */
+    public android.media.MediaPlayer setPointSourceAttributes(
+            android.media.MediaPlayer mediaPlayer,
+            com.android.extensions.xr.media.PointSourceAttributes attributes) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the {@link com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes} on
+     * the provided {@link android.media.MediaPlayer MediaPlayer}.
+     *
+     * @param mediaPlayer The {@link android.media.MediaPlayer MediaPlayer} on which to set the
+     *     attributes.
+     * @param attributes The source attributes to be set.
+     * @return The same {@link android.media.MediaPlayer MediaPlayer} instance provided.
+     */
+    public android.media.MediaPlayer setSoundFieldAttributes(
+            android.media.MediaPlayer mediaPlayer,
+            com.android.extensions.xr.media.SoundFieldAttributes attributes) {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/PointSourceAttributes.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/PointSourceAttributes.java
new file mode 100644
index 0000000..383187d
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/PointSourceAttributes.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * {@link com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes} is used to
+ * configure a sound be spatialized as a 3D point.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PointSourceAttributes {
+
+    PointSourceAttributes() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * The {@link com.android.extensions.xr.node.Node Node} to which this sound source is attached.
+     * The sound source will use the 3D transform of the Node. The node returned from this method
+     * must be parented to a node in the scene.
+     *
+     * @return The {@link com.android.extensions.xr.node.Node Node} to which the sound source is
+     *     attached.
+     */
+    public com.android.extensions.xr.node.Node getNode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Builder class for {@link com.android.extensions.xr.media.PointSourceAttributes
+     * PointSourceAttributes}
+     */
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static final class Builder {
+
+        public Builder() {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * @param node The {@link com.android.extensions.xr.node.Node Node} to use to position the
+         *     sound source.
+         * @return The Builder instance.
+         */
+        public com.android.extensions.xr.media.PointSourceAttributes.Builder setNode(
+                com.android.extensions.xr.node.Node node) {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * Creates a new {@link com.android.extensions.xr.media.PointSourceAttributes
+         * PointSourceAttributes} to be used. If no {@link com.android.extensions.xr.node.Node Node}
+         * is provided, this will create a new {@link com.android.extensions.xr.node.Node Node} that
+         * must be parented to a node in the current scene.
+         *
+         * @return A new {@link com.android.extensions.xr.media.PointSourceAttributes
+         *     PointSourceAttributes} object.
+         */
+        public com.android.extensions.xr.media.PointSourceAttributes build()
+                throws java.lang.UnsupportedOperationException {
+            throw new RuntimeException("Stub!");
+        }
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundFieldAttributes.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundFieldAttributes.java
new file mode 100644
index 0000000..7ab72ea
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundFieldAttributes.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * {@link com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes} is used to
+ * configure ambisonic sound sources.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SoundFieldAttributes {
+
+    SoundFieldAttributes() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return The {@link com.android.extensions.xr.media.SpatializerExtensions.AmbisonicsOrder
+     *     SpatializerExtensions.AmbisonicsOrder} of this sound source.
+     */
+    public int getAmbisonicsOrder() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Builder class for {@link com.android.extensions.xr.media.SoundFieldAttributes
+     * SoundFieldAttributes}
+     */
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static final class Builder {
+
+        public Builder() {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * @param ambisonicsOrder Sets the {@link
+         *     com.android.extensions.xr.media.SpatializerExtensions.AmbisonicsOrder
+         *     SpatializerExtensions.AmbisonicsOrder} of this sound source.
+         * @return The Builder instance.
+         */
+        public com.android.extensions.xr.media.SoundFieldAttributes.Builder setAmbisonicsOrder(
+                int ambisonicsOrder) {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * Creates a new {@link com.android.extensions.xr.media.PointSourceAttributes
+         * PointSourceAttributes} to be used. If no {@link Node} is provided, this will create a new
+         * {@link Node} that must be parented to a node in the current scene.
+         *
+         * @return A new {@link com.android.extensions.xr.media.PointSourceAttributes
+         *     PointSourceAttributes} object.
+         */
+        public com.android.extensions.xr.media.SoundFieldAttributes build()
+                throws java.lang.UnsupportedOperationException {
+            throw new RuntimeException("Stub!");
+        }
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundPoolExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundPoolExtensions.java
new file mode 100644
index 0000000..841b69b
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SoundPoolExtensions.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Provides spatial audio extensions on the framework {@link android.media.SoundPool SoundPool}
+ * class.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SoundPoolExtensions {
+
+    SoundPoolExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Plays a spatialized sound effect emitted relative {@link Node} in the {@link
+     * com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}.
+     *
+     * @param soundPool The {@link android.media.SoundPool SoundPool} to use to the play the sound.
+     * @param soundID a soundId returned by the load() function.
+     * @param attributes attributes to specify sound source. {@link
+     *     com.android.extensions.xr.media.PointSourceAttributes PointSourceAttributes}
+     * @param volume volume value (range = 0.0 to 1.0)
+     * @param priority stream priority (0 = lowest priority)
+     * @param loop loop mode (0 = no loop, -1 = loop forever)
+     * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+     * @return non-zero streamID if successful, zero if failed
+     */
+    public int playAsPointSource(
+            android.media.SoundPool soundPool,
+            int soundID,
+            com.android.extensions.xr.media.PointSourceAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Plays a spatialized sound effect as a sound field.
+     *
+     * @param soundPool The {@link android.media.SoundPool SoundPool} to use to the play the sound.
+     * @param soundID a soundId returned by the load() function.
+     * @param attributes attributes to specify sound source. {@link
+     *     com.android.extensions.xr.media.SoundFieldAttributes SoundFieldAttributes}
+     * @param volume volume value (range = 0.0 to 1.0)
+     * @param priority stream priority (0 = lowest priority)
+     * @param loop loop mode (0 = no loop, -1 = loop forever)
+     * @param rate playback rate (1.0 = normal playback, range 0.5 to 2.0)
+     * @return non-zero streamID if successful, zero if failed
+     */
+    public int playAsSoundField(
+            android.media.SoundPool soundPool,
+            int soundID,
+            com.android.extensions.xr.media.SoundFieldAttributes attributes,
+            float volume,
+            int priority,
+            int loop,
+            float rate) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @param soundPool The {@link android.media.SoundPool SoundPool} to use to get its SourceType.
+     * @param streamID a streamID returned by the play(), playAsPointSource(), or
+     *     playAsSoundField().
+     * @return The {@link com.android.extensions.xr.media.SpatializerExtensions.SourceType
+     *     SpatializerExtensions.SourceType} for the given streamID.
+     */
+    public int getSpatialSourceType(android.media.SoundPool soundPool, int streamID) {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SpatializerExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SpatializerExtensions.java
new file mode 100644
index 0000000..b08d6bd
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/SpatializerExtensions.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/** Extensions of the existing {@link Spatializer} class. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatializerExtensions {
+
+    SpatializerExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Specifies spatial rendering using First Order Ambisonics */
+    public static final int AMBISONICS_ORDER_FIRST_ORDER = 0; // 0x0
+
+    /** Specifies spatial rendering using Second Order Ambisonics */
+    public static final int AMBISONICS_ORDER_SECOND_ORDER = 1; // 0x1
+
+    /** Specifies spatial rendering using Third Order Ambisonics */
+    public static final int AMBISONICS_ORDER_THIRD_ORDER = 2; // 0x2
+
+    /** The sound source has not been spatialized with the Spatial Audio SDK. */
+    public static final int SOURCE_TYPE_BYPASS = 0; // 0x0
+
+    /** The sound source has been spatialized as a 3D point source. */
+    public static final int SOURCE_TYPE_POINT_SOURCE = 1; // 0x1
+
+    /** The sound source is an ambisonics sound field. */
+    public static final int SOURCE_TYPE_SOUND_FIELD = 2; // 0x2
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/XrSpatialAudioExtensions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/XrSpatialAudioExtensions.java
new file mode 100644
index 0000000..acd9deb
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/media/XrSpatialAudioExtensions.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.media;
+
+import androidx.annotation.RestrictTo;
+
+/** Provides new functionality of existing framework APIs needed to Spatialize audio sources. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class XrSpatialAudioExtensions {
+
+    XrSpatialAudioExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return {@link com.android.extensions.xr.media.SoundPoolExtensions SoundPoolExtensions}
+     *     instance to control spatial audio from a {@link SoundPool}.
+     */
+    public com.android.extensions.xr.media.SoundPoolExtensions getSoundPoolExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return {@link com.android.extensions.xr.media.AudioTrackExtensions AudioTrackExtensions}
+     *     instance to control spatial audio from an {@link AudioTrack}.
+     */
+    public com.android.extensions.xr.media.AudioTrackExtensions getAudioTrackExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return {@link com.android.extensions.xr.media.AudioManagerExtensions AudioManagerExtensions}
+     *     instance to control spatial audio from an {@link AudioManager}.
+     */
+    public com.android.extensions.xr.media.AudioManagerExtensions getAudioManagerExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return {@link com.android.extensions.xr.media.MediaPlayerExtensions MediaPlayerExtensions}
+     *     instance to control spatial audio from a {@link MediaPlayer}.
+     */
+    public com.android.extensions.xr.media.MediaPlayerExtensions getMediaPlayerExtensions() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/InputEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/InputEvent.java
new file mode 100644
index 0000000..4f39f6c
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/InputEvent.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** A single 6DOF pointer event. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class InputEvent {
+
+    InputEvent() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the source of this event. */
+    public int getSource() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the pointer type of this event. */
+    public int getPointerType() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The time this event occurred, in the android.os.SystemClock#uptimeMillis time base. */
+    public long getTimestamp() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The origin of the ray, in the receiver's task coordinate space. */
+    public com.android.extensions.xr.node.Vec3 getOrigin() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * The direction the ray is pointing in, in the receiver's task coordinate space. Any point
+     * along the ray can be represented as origin + d * direction, where d is non-negative.
+     */
+    public com.android.extensions.xr.node.Vec3 getDirection() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Info about the first scene node (closest to the ray origin) that was hit by the input ray, if
+     * any. This will be null if no node was hit. Note that the hit node remains the same during an
+     * ongoing DOWN -> MOVE -> UP action, even if the pointer stops hitting the node during the
+     * action.
+     */
+    public com.android.extensions.xr.node.InputEvent.HitInfo getHitInfo() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Info about the second scene node from the same task that was hit, if any. */
+    public com.android.extensions.xr.node.InputEvent.HitInfo getSecondaryHitInfo() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Gets the dispatch flags. */
+    public int getDispatchFlags() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current action associated with this input event. */
+    public int getAction() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * While the primary action button or gesture was held, the pointer was disabled. This happens
+     * if you are using controllers and the battery runs out, or if you are using a source that
+     * transitions to a new pointer type, eg SOURCE_GAZE_AND_GESTURE.
+     */
+    public static final int ACTION_CANCEL = 3; // 0x3
+
+    /** The primary action button or gesture was just pressed / started. */
+    public static final int ACTION_DOWN = 0; // 0x0
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray started to hit a new
+     * node. The hit info represents the node that is being hit (may be null if pointer capture is
+     * enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    public static final int ACTION_HOVER_ENTER = 5; // 0x5
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray stopped hitting the
+     * node that it was previously hitting. The hit info represents the node that was being hit (may
+     * be null if pointer capture is enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    public static final int ACTION_HOVER_EXIT = 6; // 0x6
+
+    /**
+     * The primary action button or gesture is not pressed, and the pointer ray continued to hit the
+     * same node. The hit info represents the node that was hit (may be null if pointer capture is
+     * enabled).
+     *
+     * <p>Hover input events are never provided for sensitive source types.
+     */
+    public static final int ACTION_HOVER_MOVE = 4; // 0x4
+
+    /**
+     * The primary action button or gesture was pressed/active in the previous event, and is still
+     * pressed/active. The hit info represents the node that was originally hit (ie, as provided in
+     * the ACTION_DOWN event). The hit position may be null if the pointer is no longer hitting that
+     * node.
+     */
+    public static final int ACTION_MOVE = 2; // 0x2
+
+    /**
+     * The primary action button or gesture was just released / stopped. The hit info represents the
+     * node that was originally hit (ie, as provided in the ACTION_DOWN event).
+     */
+    public static final int ACTION_UP = 1; // 0x1
+
+    /** This event was also dispatched as a 2D Android input event. */
+    public static final int DISPATCH_FLAG_2D = 2; // 0x2
+
+    /** This event was dispatched to this receiver only because pointer capture was enabled. */
+    public static final int DISPATCH_FLAG_CAPTURED_POINTER = 1; // 0x1
+
+    /** Normal dispatch. */
+    public static final int DISPATCH_FLAG_NONE = 0; // 0x0
+
+    /**
+     * Default pointer type for the source (no handedness). Occurs for SOURCE_UNKNOWN, SOURCE_HEAD,
+     * SOURCE_MOUSE, and SOURCE_GAZE_AND_GESTURE.
+     */
+    public static final int POINTER_TYPE_DEFAULT = 0; // 0x0
+
+    /**
+     * Left hand / controller pointer.. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+     * SOURCE_GAZE_AND_GESTURE.
+     */
+    public static final int POINTER_TYPE_LEFT = 1; // 0x1
+
+    /**
+     * Right hand / controller pointer.. Occurs for SOURCE_CONTROLLER, SOURCE_HANDS, and
+     * SOURCE_GAZE_AND_GESTURE.
+     */
+    public static final int POINTER_TYPE_RIGHT = 2; // 0x2
+
+    /**
+     * Event is based on (one of) the user's controller(s). Ray origin and direction are for a
+     * controller aim pose as defined by OpenXR.
+     * (https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#semantic-paths-standard-pose-identifiers)
+     * Action state is based on the primary button on the controller, usually the bottom-most face
+     * button.
+     */
+    public static final int SOURCE_CONTROLLER = 2; // 0x2
+
+    /**
+     * Event is based on a mix of the head, eyes, and hands. Ray origin is at average between eyes
+     * and points in direction based on a mix of eye gaze direction and hand motion. During a
+     * two-handed zoom/rotate gesture, left/right pointer events will be issued; otherwise, default
+     * events are issued based on the gaze ray. Action state is based on if the user has done a
+     * pinch gesture or not.
+     *
+     * <p>Events from this device type are considered sensitive and hover events are never sent.
+     */
+    public static final int SOURCE_GAZE_AND_GESTURE = 5; // 0x5
+
+    /**
+     * Event is based on one of the user's hands. Ray is a hand aim pose, with origin between thumb
+     * and forefinger and points in direction based on hand orientation. Action state is based on a
+     * pinch gesture.
+     */
+    public static final int SOURCE_HANDS = 3; // 0x3
+
+    /**
+     * Event is based on the user's head. Ray origin is at average between eyes, pushed out to the
+     * near clipping plane for both eyes and points in direction head is facing. Action state is
+     * based on volume up button being depressed.
+     *
+     * <p>Events from this device type are considered sensitive and hover events are never sent.
+     */
+    public static final int SOURCE_HEAD = 1; // 0x1
+
+    /**
+     * Event is based on a 2D mouse pointing device. Ray origin behaves the same as for
+     * DEVICE_TYPE_HEAD and points in direction based on mouse movement. During a drag, the ray
+     * origin moves approximating hand motion. The scrollwheel moves the ray away from / towards the
+     * user. Action state is based on the primary mouse button.
+     */
+    public static final int SOURCE_MOUSE = 4; // 0x4
+
+    public static final int SOURCE_UNKNOWN = 0; // 0x0
+
+    /** Info about a single ray hit. */
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static class HitInfo {
+
+        public HitInfo(
+                int subspaceImpressNodeId,
+                com.android.extensions.xr.node.Node inputNode,
+                com.android.extensions.xr.node.Mat4f transform,
+                com.android.extensions.xr.node.Vec3 hitPosition) {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * ID of the front-end Impress node within the subspace that was hit. Used by Split-Engine
+         * to create a handle to the node with the same entity ID. In case the node doesn't belong
+         * to a subspace the value will be 0, i.e.,
+         * utils::Entity::import(subspaceImpressNodeId).IsNull() == true.
+         *
+         * <p>ACTION_MOVE, ACTION_UP, and ACTION_CANCEL events will report the same node id as was
+         * hit during the initial ACTION_DOWN.
+         */
+        public int getSubspaceImpressNodeId() {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * The CPM node that was hit.
+         *
+         * <p>ACTION_MOVE, ACTION_UP, and ACTION_CANCEL events will report the same node as was hit
+         * during the initial ACTION_DOWN.
+         */
+        public com.android.extensions.xr.node.Node getInputNode() {
+            throw new RuntimeException("Stub!");
+        }
+
+        /**
+         * The ray hit position, in the receiver's task coordinate space.
+         *
+         * <p>All events may report the current ray's hit position. This can be null if there no
+         * longer is a collision between the ray and the input node (eg, during a drag event).
+         */
+        public com.android.extensions.xr.node.Vec3 getHitPosition() {
+            throw new RuntimeException("Stub!");
+        }
+
+        /** The matrix transforming task node coordinates into the hit CPM node's coordinates. */
+        public com.android.extensions.xr.node.Mat4f getTransform() {
+            throw new RuntimeException("Stub!");
+        }
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Mat4f.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Mat4f.java
new file mode 100644
index 0000000..e98f907
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Mat4f.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** 4x4 matrix. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class Mat4f {
+
+    public Mat4f(float[] m) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Gets a value from the matrix. */
+    public float get(int row, int column) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Sets a value in the matrix. */
+    public void set(int row, int column, float value) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Gets the values of the matrix as an array. */
+    public float[] getFlattenedMatrix() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Node.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Node.java
new file mode 100644
index 0000000..0a8589e
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Node.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Handle to a node in the SpaceFlinger scene graph that can also host a 2D Panel or 3D subspace.
+ *
+ * <p>A Node by itself does not have any visual representation. It merely defines a local space in
+ * its parent space. However, a node can also host a single 2D panel or 3D subspace. Once an element
+ * is hosted, the node must be attached to the rest of scene graph hierarchy for the element become
+ * visible and appear on-screen.
+ *
+ * <p>Note that {@link com.android.extensions.xr.node.Node Node} uses a right-hand coordinate
+ * system, i.e. +X points to the right, +Y up, and +Z points towards the camera.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Node implements android.os.Parcelable {
+
+    Node() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Begins listening for 6DOF input events on this Node, and any descendant Nodes that do not
+     * have their own event listener set. The event listener is called on the provided Executor.
+     * Calling this method replaces any existing event listener for this node.
+     */
+    public void listenForInput(
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.InputEvent>
+                    listener,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Removes the listener for 6DOF input events from this Node. */
+    public void stopListeningForInput() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the focus target for non-pointer input (eg, keyboard events) when this Node is clicked.
+     * The new target is the focusTarget's underlying View Root.
+     */
+    public void setNonPointerFocusTarget(android.view.AttachedSurfaceControl focusTarget) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Requests pointer capture. All XR input events that hit this node or any of its children are
+     * delivered as normal; any other input events that would otherwise be dispatched elsewhere will
+     * instead be delivered to the input queue of this node (without hit info).
+     *
+     * <p>The stateCallback is called immediately with the current state of this pointer capture.
+     * Whenever this node is visible and a descendant of a task that is not bounded (is in FSM or
+     * overlay space), pointer capture will be active; otherwise it will be paused.
+     *
+     * <p>If pointer capture is explicitly stopped by a new call to requestPointerCapture() on the
+     * same node, or by a call to stopPointerCapture(), POINTER_CAPTURE_STATE_STOPPED is passed (and
+     * the stateCallback will not be called subsequently; also, the app can be sure that no more
+     * captured pointer events will be delivered based on that request). This also occurs if the
+     * node is destroyed without explicitly stopping pointer capture, or if a new call to
+     * requestPointerCapture() is made on the same node without stopping the previous request.
+     *
+     * <p>If there are multiple pointer capture requests (eg from other apps) that could be active
+     * at the same time, the most recently requested one is activated; all other requests stay
+     * paused.
+     *
+     * <p>There can only be a single request per Node. If a new requestPointerCapture() call is made
+     * on the same node without stopping the previous pointer capture request, the previous request
+     * is automatically stopped.
+     *
+     * @param stateCallback a callback that will be called when pointer capture state changes.
+     * @param executor the executor the callback will be called on.
+     */
+    public void requestPointerCapture(
+            com.android.extensions.xr.function.Consumer<java.lang.Integer> stateCallback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Disables previously-requested pointer capture on this node. The stateCallback callback will
+     * be called with POINTER_CAPTURE_STOPPED.
+     */
+    public void stopPointerCapture() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Subscribes to the transform of this node, relative to the OpenXR reference space used as
+     * world space for the shared scene. See {@code XrExtensions.getOpenXrWorldSpaceType()}. The
+     * provided matrix transforms a point in this node's local coordinate system into a point in
+     * world space coordinates. For example, {@code NodeTransform.getTransform()} * (0, 0, 0, 1) is
+     * the position of this node in world space. The first non-null transform will be returned
+     * immediately after the subscription set-up is complete. Note that the returned closeable must
+     * be closed by calling {@code close()} to prevent wasting system resources associated with the
+     * subscription.
+     *
+     * @param transformCallback a callback that will be called when this node's transform changes.
+     * @param executor the executor the callback will be called on.
+     * @return a Closeable that must be used to cancel the subscription by calling {@code close()}.
+     */
+    public java.io.Closeable subscribeToTransform(
+            com.android.extensions.xr.function.Consumer<
+                            com.android.extensions.xr.node.NodeTransform>
+                    transformCallback,
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** A no-op override.  */
+    public int describeContents() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Writes the Node to a Parcel. */
+    public void writeToParcel(android.os.Parcel out, int flags) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object object) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    public static final int POINTER_CAPTURE_STATE_ACTIVE = 1; // 0x1
+
+    public static final int POINTER_CAPTURE_STATE_PAUSED = 0; // 0x0
+
+    public static final int POINTER_CAPTURE_STATE_STOPPED = 2; // 0x2
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransaction.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransaction.java
new file mode 100644
index 0000000..1983264
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransaction.java
@@ -0,0 +1,510 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * An atomic set of changes to apply to a set of {@link com.android.extensions.xr.node.Node Node}s.
+ *
+ * <p>Note that {@link com.android.extensions.xr.node.Node Node} uses a right-hand coordinate
+ * system, i.e. +X points to the right, +Y up, and +Z points towards the camera.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class NodeTransaction implements java.io.Closeable {
+
+    NodeTransaction() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets a name for the node that is used to it in `adb dumpsys cpm` output log.
+     *
+     * <p>While the name does not have to be globally unique, it is recommended to set a unique name
+     * for each node for ease of debugging.
+     *
+     * @param node The node to be updated.
+     * @param name The debug name of the node.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setName(
+            com.android.extensions.xr.node.Node node, java.lang.String name) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the parent of this node to the given node.
+     *
+     * <p>This method detaches the node from its current branch and moves into the new parent's
+     * hierarchy (if any). If parent parameter is `null`, the node will be orphaned and removed from
+     * the rendering tree until it is reattached to another node that is in the root hierarchy.
+     *
+     * @param node The node to be updated.
+     * @param parent The new parent of the node or `null` if the node is to be removed from the
+     *     rendering tree.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setParent(
+            com.android.extensions.xr.node.Node node, com.android.extensions.xr.node.Node parent) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the position of the node in the local coordinate space (parent space).
+     *
+     * @param node The node to be updated.
+     * @param x The 'x' distance in meters from parent's origin.
+     * @param y The 'y' distance in meters from parent's origin.
+     * @param z The 'z' distance in meters from parent's origin.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setPosition(
+            com.android.extensions.xr.node.Node node, float x, float y, float z) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Rotates the node by the quaternion specified by x, y, z, and w components in the local
+     * coordinate space.
+     *
+     * @param node The node to be updated.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setOrientation(
+            com.android.extensions.xr.node.Node node, float x, float y, float z, float w) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Scales the node along the x, y, and z axis in the local coordinate space.
+     *
+     * <p>For 2D panels, this method scales the panel in the world, increasing its visual size
+     * without changing the buffer size. It will not trigger a relayout and will not affect its
+     * enclosing view's layout configuration.
+     *
+     * @param node The node to be updated.
+     * @param sx The scaling factor along the x-axis.
+     * @param sy The scaling factor along the y-axis.
+     * @param sz The scaling factor along the z-axis.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setScale(
+            com.android.extensions.xr.node.Node node, float sx, float sy, float sz) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the opacity of the node's content to a value between [0..1].
+     *
+     * @param value The new opacity amount in range of [0..1].
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setAlpha(
+            com.android.extensions.xr.node.Node node, float value) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Changes the visibility of the node and its content.
+     *
+     * @param isVisible Whether the node is visible.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setVisibility(
+            com.android.extensions.xr.node.Node node, boolean isVisible) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Configures the node to host and control the given surface data.
+     *
+     * <p>Passing a 'null' for surfaceControl parameter will disassociate it from the node, so the
+     * same node can be used to host another surface or volume data.
+     *
+     * @param node The node to be updated.
+     * @param surfaceControl Handle to an on-screen surface managed by the system compositor, or
+     *     'null' to disassociate the currently hosted surface from the node.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setSurfaceControl(
+            com.android.extensions.xr.node.Node node, android.view.SurfaceControl surfaceControl) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Configures the node to host and control the given surface data.
+     *
+     * <p>This method is similar to {@link
+     * #setSurfaceControl(com.android.extensions.xr.node.Node,android.view.SurfaceControl)} and is
+     * provided for convenience.
+     *
+     * @param node The node to be updated.
+     * @param surfacePackage The package that contains the {@link android.view.SurfaceControl
+     *     SurfaceControl}, or 'null' to disassociate the currently hosted surface from the node.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setSurfacePackage(
+            com.android.extensions.xr.node.Node node,
+            android.view.SurfaceControlViewHost.SurfacePackage surfacePackage) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Crops the 2D buffer of the Surface hosted by this node to match the given bounds in pixels.
+     *
+     * <p>This method only applies to nodes that host a {@link android.view.SurfaceControl
+     * SurfaceControl} set by {@link #setSurfaceControl}.
+     *
+     * @param surfaceControl The on-screen surface.
+     * @param widthPx The width of the surface in pixels.
+     * @param heightPx The height of the surface in pixels.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setWindowBounds(
+            android.view.SurfaceControl surfaceControl, int widthPx, int heightPx) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Crops the 2D buffer of the Surface hosted by this node to match the given bounds in pixels.
+     *
+     * <p>This method is similar to {@link #setWindowBounds(android.view.SurfaceControl,int,int)}
+     * and is provided for convenience.
+     *
+     * @param surfacePackage The package that contains the {@link android.view.SurfaceControl
+     *     SurfaceControl}.
+     * @param widthPx The width of the surface in pixels.
+     * @param heightPx The height of the surface in pixels.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setWindowBounds(
+            android.view.SurfaceControlViewHost.SurfacePackage surfacePackage,
+            int widthPx,
+            int heightPx) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Curves the XY plane of the node around the y-axis and towards the positive z-axis.
+     *
+     * <p>This method essentially curves the x-axis of the node, moving and rotating its children to
+     * align with the new x-axis shape. It will also curve the children's x-axes in a similar
+     * manner.
+     *
+     * <p>If this node is hosting a 2D panel, setting a curvature will bend the panel along the Y
+     * axis, projecting it onto a cylinder defined by the given radius.
+     *
+     * <p>To remove the curvature, set the radius to 0.
+     *
+     * @param node The node to be updated.
+     * @param curvature A positive value equivalent to 1/radius, where 'radius' represents the
+     *     radial distance of the polar coordinate system that is used to curve the x-axis. Setting
+     *     this value to 0 will straighten the axis and remove its curvature.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     * @deprecated Use Split Engine to create a curved panel.
+     */
+    @Deprecated
+    public com.android.extensions.xr.node.NodeTransaction setCurvature(
+            com.android.extensions.xr.node.Node node, float curvature) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the resolution of 2D surfaces under this node.
+     *
+     * <p>The sizes of 2D surfaces under this node will be set according to their 2D pixel
+     * dimensions and the pixelsPerMeter value. The pixelsPerMeter value is propagated to child
+     * nodes.
+     *
+     * @param node The node to be updated.
+     * @param pixelsPerMeter The number of pixels per meter to use when sizing 2D surfaces.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setPixelResolution(
+            com.android.extensions.xr.node.Node node, float pixelsPerMeter) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets whether position is interpreted in meters or in pixels for each dimension.
+     *
+     * <p>The sizes of 2D surfaces under this node will be set according to their 2D pixel
+     * dimensions and the pixelsPerMeter value. The pixelsPerMeter value is propagated to child
+     * nodes.
+     *
+     * @param node The node to be updated.
+     * @param pixelPositionFlags Flags indicating which dimensins of the local position of the node
+     *     should be interpreted as pixel values (as opposed to the default meters).
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setPixelPositioning(
+            com.android.extensions.xr.node.Node node, int pixelPositionFlags) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Renders a previously loaded glTF model.
+     *
+     * <p>The token must belong to a previously loaded glTF model that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param gltfModelToken The token of a glTF model that was previously loaded.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public com.android.extensions.xr.node.NodeTransaction setGltfModel(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.asset.GltfModelToken gltfModelToken) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Renders a previously loaded environment.
+     *
+     * <p>The token must belong to a previously loaded environment that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param environmentToken The token of an environment that was previously loaded.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public com.android.extensions.xr.node.NodeTransaction setEnvironment(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.asset.EnvironmentToken environmentToken) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Renders a previously loaded Impress scene.
+     *
+     * <p>The token must belong to a previously loaded Impress scene that is currently cached in the
+     * SpaceFlinger.
+     *
+     * @param node The node to be updated.
+     * @param sceneToken The token of an Impress scene that was previously loaded.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public com.android.extensions.xr.node.NodeTransaction setImpressScene(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.asset.SceneToken sceneToken) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Animates a previously loaded glTF model.
+     *
+     * @param node The node to be updated.
+     * @param gltfAnimationName The name of the glTF animation.
+     * @param gltfAnimationState The {@link com.android.extensions.xr.asset.GltfAnimation.State
+     *     GltfAnimation.State} state of the glTF animation.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     * @deprecated JXR Core doesn't need this anymore as it does the same with Split Engine.
+     */
+    @Deprecated
+    public com.android.extensions.xr.node.NodeTransaction setGltfAnimation(
+            com.android.extensions.xr.node.Node node,
+            java.lang.String gltfAnimationName,
+            com.android.extensions.xr.asset.GltfAnimation.State gltfAnimationState) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the transform of the node on a per-frame basis from a previously created anchor.
+     *
+     * <p>The client who created the anchor and provided the ID will always remain the owner of the
+     * anchor.
+     *
+     * <p>Modifying the transform of the node will only be applied if or when the anchor is no
+     * longer linked to the node, or if the anchor is no longer locatable.
+     *
+     * <p>A node can be unlinked from an anchor by setting the ID to null. Note that this does not
+     * destroy the actual anchor.
+     *
+     * @param node The node to be updated.
+     * @param anchorId The ID of a previously created anchor.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setAnchorId(
+            com.android.extensions.xr.node.Node node, android.os.IBinder anchorId) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets a subspace to be used.
+     *
+     * @param node The node to be updated.
+     * @param subspace The previously created subspace to be associated with the node.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setSubspace(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.subspace.Subspace subspace) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Updates the passthrough state.
+     *
+     * @param node The node to be updated.
+     * @param passthroughOpacity The opacity of the passthrough layer where 0.0 means no passthrough
+     *     and 1.0 means full passthrough.
+     * @param passthroughMode The {@link com.android.extensions.xr.passthrough.PassthroughState.Mode
+     *     PassthroughState.Mode} mode that the passthrough will use.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setPassthroughState(
+            com.android.extensions.xr.node.Node node,
+            float passthroughOpacity,
+            int passthroughMode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Enables reform UX for a node.
+     *
+     * @param node The node to be updated.
+     * @param options Configuration options for the reform UX.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction enableReform(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.node.ReformOptions options) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Updates the size of the reform UX.
+     *
+     * @param node The node to be updated.
+     * @param reformSize The new size in meters that should be used to lay out the reform UX.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setReformSize(
+            com.android.extensions.xr.node.Node node,
+            com.android.extensions.xr.node.Vec3 reformSize) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Disables reform UX for a node.
+     *
+     * @param node The node to be updated.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction disableReform(
+            com.android.extensions.xr.node.Node node) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Sets the corner radius for 2D surfaces under this node.
+     *
+     * <p>The corner radius is propagated to child nodes.
+     *
+     * @param node The node to be updated.
+     * @param cornerRadius The corner radius for 2D surfaces under this node, in meters.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction setCornerRadius(
+            com.android.extensions.xr.node.Node node, float cornerRadius) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Removes the corner radius from this node.
+     *
+     * @param node The node to be updated.
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction removeCornerRadius(
+            com.android.extensions.xr.node.Node node) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Merges the given transaction into this one so that they can be submitted together to the
+     * system. All of the changes in the other transaction are moved into this one; the other
+     * transaction is left in an empty state.
+     *
+     * @return The reference to this {@link com.android.extensions.xr.node.NodeTransaction
+     *     NodeTransaction} object that is currently being updated.
+     */
+    public com.android.extensions.xr.node.NodeTransaction merge(
+            com.android.extensions.xr.node.NodeTransaction other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Submits the queued transactions to backend.
+     *
+     * <p>This method will clear the existing transaction state so the same transaction object can
+     * be used for the next set of updates.
+     */
+    public void apply() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Closes and releases the native transaction object without applying it.
+     *
+     * <p>Note that a closed transaction cannot be used again.
+     */
+    public void close() {
+        throw new RuntimeException("Stub!");
+    }
+
+    public static final int POSITION_FROM_PARENT_TOP_LEFT = 64; // 0x40
+
+    public static final int X_POSITION_IN_PIXELS = 1; // 0x1
+
+    public static final int Y_POSITION_IN_PIXELS = 2; // 0x2
+
+    public static final int Z_POSITION_IN_PIXELS = 4; // 0x4
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransform.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransform.java
new file mode 100644
index 0000000..189dd51
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/NodeTransform.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** interface containing the Node transform */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class NodeTransform {
+
+    NodeTransform() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Get the transformation matrix associated with the node.
+     *
+     * <p>The provided matrix transforms a point in this node's local coordinate system into a point
+     * in world space coordinates. For example, {@code NodeTransform.getTransform()} * (0, 0, 0, 1)
+     * is the position of this node in world space. The first non-null transform will be returned
+     * immediately after the subscription set-up is complete.
+     *
+     * @return A transformation matrix {@link com.android.extensions.xr.node.Mat4f Mat4f} containing
+     *     the current transformation matrix of this node.
+     */
+    public com.android.extensions.xr.node.Mat4f getTransform() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Get the timestamp at which the transformation matrix was recorded.
+     *
+     * <p>The time the record happened, in the android.os.SystemClock#uptimeNanos time base.
+     *
+     * @return A timestamp at which the transformation matrix was recorded.
+     */
+    public long getTimestamp() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Quatf.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Quatf.java
new file mode 100644
index 0000000..9879687
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Quatf.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** Quaternion. q = w + xi + yj + zk */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class Quatf {
+
+    public Quatf(float x, float y, float z, float w) {
+        throw new RuntimeException("Stub!");
+    }
+
+    public final float w;
+
+    {
+        w = 0;
+    }
+
+    public final float x;
+
+    {
+        x = 0;
+    }
+
+    public final float y;
+
+    {
+        y = 0;
+    }
+
+    public final float z;
+
+    {
+        z = 0;
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformEvent.java
new file mode 100644
index 0000000..fc8a5f0
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformEvent.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** A reform (move / resize) event. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ReformEvent {
+
+    ReformEvent() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Gets the event type. */
+    public int getType() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Gets the event state. */
+    public int getState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** An identifier for this reform action. */
+    public int getId() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The initial ray origin and direction, in task space. */
+    public com.android.extensions.xr.node.Vec3 getInitialRayOrigin() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The initial ray direction, in task space. */
+    public com.android.extensions.xr.node.Vec3 getInitialRayDirection() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The current ray origin and direction, in task space. */
+    public com.android.extensions.xr.node.Vec3 getCurrentRayOrigin() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The current ray direction, in task space. */
+    public com.android.extensions.xr.node.Vec3 getCurrentRayDirection() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * For a move event, the proposed pose of the node, in task space (or relative to the parent
+     * node, if FLAG_POSE_RELATIVE_TO_PARENT was specified in the ReformOptions).
+     */
+    public com.android.extensions.xr.node.Vec3 getProposedPosition() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** For a move event, the proposed orientation of the node, in task space. */
+    public com.android.extensions.xr.node.Quatf getProposedOrientation() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Scale will change with distance if ReformOptions.FLAG_SCALE_WITH_DISTANCE is set. */
+    public com.android.extensions.xr.node.Vec3 getProposedScale() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * For a resize event, the proposed new size in meters. Note that in the initial implementation,
+     * the Z size may not be modified.
+     */
+    public com.android.extensions.xr.node.Vec3 getProposedSize() {
+        throw new RuntimeException("Stub!");
+    }
+
+    public static final int REFORM_STATE_END = 3; // 0x3
+
+    public static final int REFORM_STATE_ONGOING = 2; // 0x2
+
+    public static final int REFORM_STATE_START = 1; // 0x1
+
+    public static final int REFORM_STATE_UNKNOWN = 0; // 0x0
+
+    public static final int REFORM_TYPE_MOVE = 1; // 0x1
+
+    public static final int REFORM_TYPE_RESIZE = 2; // 0x2
+
+    public static final int REFORM_TYPE_UNKNOWN = 0; // 0x0
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformOptions.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformOptions.java
new file mode 100644
index 0000000..3c0242c
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/ReformOptions.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Configuration options for reform (move/resize) UX. To create a ReformOptions instance, call
+ * {@code XrExtensions.createReformOptions()}.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ReformOptions {
+
+    ReformOptions() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Which reform actions are enabled. */
+    public int getEnabledReform() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** By default, only ALLOW_MOVE is enabled. */
+    public com.android.extensions.xr.node.ReformOptions setEnabledReform(int enabledReform) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Behaviour flags. */
+    public int getFlags() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** By default, the flags are set to 0. */
+    public com.android.extensions.xr.node.ReformOptions setFlags(int flags) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Current size of the content, in meters. This is the local size (does not include any scale
+     * factors)
+     */
+    public com.android.extensions.xr.node.Vec3 getCurrentSize() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** By default, the current size is set to (1, 1, 1). */
+    public com.android.extensions.xr.node.ReformOptions setCurrentSize(
+            com.android.extensions.xr.node.Vec3 currentSize) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Minimum size of the content, in meters. This is a local size. */
+    public com.android.extensions.xr.node.Vec3 getMinimumSize() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** By default, the minimum size is set to (1, 1, 1). */
+    public com.android.extensions.xr.node.ReformOptions setMinimumSize(
+            com.android.extensions.xr.node.Vec3 minimumSize) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Maximum size of the content, in meters. This is a local size. */
+    public com.android.extensions.xr.node.Vec3 getMaximumSize() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** By default, the maximum size is set to (1, 1, 1). */
+    public com.android.extensions.xr.node.ReformOptions setMaximumSize(
+            com.android.extensions.xr.node.Vec3 maximumSize) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The aspect ratio of the content on resizing. <= 0.0f when there are no preferences. */
+    public float getFixedAspectRatio() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * The aspect ratio determined by taking the panel's width over its height. An aspect ratio
+     * value less than 0 will be ignored. A value <= 0.0f means there are no preferences.
+     */
+    public com.android.extensions.xr.node.ReformOptions setFixedAspectRatio(
+            float fixedAspectRatio) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current value of forceShowResizeOverlay. */
+    public boolean getForceShowResizeOverlay() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * If forceShowResizeOverlay is set to true, the resize overlay will always be show (until
+     * forceShowResizeOverlay is changed to false). This can be used by apps to implement their own
+     * resize affordances.
+     */
+    public com.android.extensions.xr.node.ReformOptions setForceShowResizeOverlay(
+            boolean forceShowResizeOverlay) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the callback that will receive reform events. */
+    public com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent>
+            getEventCallback() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Sets the callback that will receive reform events. */
+    public com.android.extensions.xr.node.ReformOptions setEventCallback(
+            com.android.extensions.xr.function.Consumer<com.android.extensions.xr.node.ReformEvent>
+                    callback) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the executor that events will be handled on. */
+    public java.util.concurrent.Executor getEventExecutor() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Sets the executor that events will be handled on. */
+    public com.android.extensions.xr.node.ReformOptions setEventExecutor(
+            java.util.concurrent.Executor executor) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns the current value of scaleWithDistanceMode. */
+    public int getScaleWithDistanceMode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * If scaleWithDistanceMode is set, and FLAG_SCALE_WITH_DISTANCE is also in use, the scale the
+     * system suggests (or automatically applies when FLAG_ALLOW_SYSTEM_MOVEMENT is also in use)
+     * follows scaleWithDistanceMode:
+     *
+     * <p>SCALE_WITH_DISTANCE_MODE_DEFAULT: The panel scales in the same way as home space mode.
+     * SCALE_WITH_DISTANCE_MODE_DMM: The panel scales in a way that the user-perceived panel size
+     * never changes.
+     *
+     * <p>When FLAG_SCALE_WITH_DISTANCE is not in use, scaleWithDistanceMode is ignored.
+     */
+    public com.android.extensions.xr.node.ReformOptions setScaleWithDistanceMode(
+            int scaleWithDistanceMode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    public static final int ALLOW_MOVE = 1; // 0x1
+
+    public static final int ALLOW_RESIZE = 2; // 0x2
+
+    public static final int FLAG_ALLOW_SYSTEM_MOVEMENT = 2; // 0x2
+
+    public static final int FLAG_POSE_RELATIVE_TO_PARENT = 4; // 0x4
+
+    public static final int FLAG_SCALE_WITH_DISTANCE = 1; // 0x1
+
+    public static final int SCALE_WITH_DISTANCE_MODE_DEFAULT = 3; // 0x3
+
+    public static final int SCALE_WITH_DISTANCE_MODE_DMM = 2; // 0x2
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Vec3.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Vec3.java
new file mode 100644
index 0000000..746d909
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/node/Vec3.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.node;
+
+import androidx.annotation.RestrictTo;
+
+/** 3D vector. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class Vec3 {
+
+    public Vec3(float x, float y, float z) {
+        throw new RuntimeException("Stub!");
+    }
+
+    public final float x;
+
+    {
+        x = 0;
+    }
+
+    public final float y;
+
+    {
+        y = 0;
+    }
+
+    public final float z;
+
+    {
+        z = 0;
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/passthrough/PassthroughState.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/passthrough/PassthroughState.java
new file mode 100644
index 0000000..42f3901
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/passthrough/PassthroughState.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.passthrough;
+
+import androidx.annotation.RestrictTo;
+
+/** Allows to configure the passthrough when the application is in full-space mode. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class PassthroughState {
+
+    PassthroughState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Node maximizes the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_MAX = 1; // 0x1
+
+    /** Node minimizes the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_MIN = 2; // 0x2
+
+    /** Node does not contribute to the opacity of the final passthrough state. */
+    public static final int PASSTHROUGH_MODE_OFF = 0; // 0x0
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanel.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanel.java
new file mode 100644
index 0000000..c33ca4a
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanel.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Defines the panel in XR scene to support embedding activities within a host activity.
+ *
+ * <p>When the host activity is destroyed, all the activities in its embedded {@link
+ * com.android.extensions.xr.space.ActivityPanel ActivityPanel} will also be destroyed.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class ActivityPanel {
+
+    ActivityPanel() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Launches an activity into this panel.
+     *
+     * @param intent the {@link android.content.Intent Intent} to start.
+     * @param options additional options for how the Activity should be started.
+     */
+    public void launchActivity(android.content.Intent intent, android.os.Bundle options) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Moves an existing activity into this panel.
+     *
+     * @param activity the {@link android.app.Activity Activity} to move.
+     */
+    public void moveActivity(android.app.Activity activity) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the node associated with this {@link com.android.extensions.xr.space.ActivityPanel
+     * ActivityPanel}.
+     *
+     * <p>The {@link com.android.extensions.xr.space.ActivityPanel ActivityPanel} can only be shown
+     * to the user after this node is attached to the host activity's scene.
+     *
+     * @see androidx.xr.extensions.XrExtensions#attachSpatialScene
+     */
+    public com.android.extensions.xr.node.Node getNode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Updates the 2D window bounds of this {@link com.android.extensions.xr.space.ActivityPanel
+     * ActivityPanel}.
+     *
+     * <p>If the new bounds are smaller that the minimum dimensions of the activity embedded in this
+     * ActivityPanel, the ActivityPanel bounds will be reset to match the host Activity bounds.
+     *
+     * @param windowBounds the new 2D window bounds in the host container window coordinates.
+     */
+    public void setWindowBounds(android.graphics.Rect windowBounds) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Deletes the activity panel. All the activities in this {@link
+     * com.android.extensions.xr.space.ActivityPanel ActivityPanel} will also be destroyed.
+     */
+    public void delete() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanelLaunchParameters.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanelLaunchParameters.java
new file mode 100644
index 0000000..cf58741
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/ActivityPanelLaunchParameters.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Defines the launch parameters when creating an {@link
+ * com.android.extensions.xr.space.ActivityPanel ActivityPanel}.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public final class ActivityPanelLaunchParameters {
+
+    /**
+     * Constructs an {@link com.android.extensions.xr.space.ActivityPanelLaunchParameters
+     * ActivityPanelLaunchParameters} with the given initial window bounds.
+     *
+     * @param windowBounds the initial 2D window bounds of the panel, which will be the bounds of
+     *     the Activity launched into the {@link com.android.extensions.xr.space.ActivityPanel
+     *     ActivityPanel}.
+     */
+    public ActivityPanelLaunchParameters(android.graphics.Rect windowBounds) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * @return the initial 2D window bounds.
+     */
+    public android.graphics.Rect getWindowBounds() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/Bounds.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/Bounds.java
new file mode 100644
index 0000000..5562fc58d
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/Bounds.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Bounds values in meters.
+ *
+ * @see androidx.xr.extensions.XrExtensions#getSpatialState
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Bounds {
+
+    public Bounds(float width, float height, float depth) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The bounds width. */
+    public float getWidth() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The bounds height. */
+    public float getHeight() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The bounds depth. */
+    public float getDepth() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/BoundsChangeEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/BoundsChangeEvent.java
new file mode 100644
index 0000000..255c6198
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/BoundsChangeEvent.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when there is a bounds change. For example, resize the panel in home space, or
+ * enter/exit FSM.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public final class BoundsChangeEvent implements com.android.extensions.xr.space.SpatialStateEvent {
+
+    @Deprecated
+    public BoundsChangeEvent(com.android.extensions.xr.space.Bounds bounds) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Width of the bounds in meters.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public float getWidth() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Height of the bounds in meters.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public float getHeight() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Depth of the bounds in meters.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public float getDepth() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Bounds in meters.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public com.android.extensions.xr.space.Bounds getBounds() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentControlChangeEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentControlChangeEvent.java
new file mode 100644
index 0000000..73ec74e
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentControlChangeEvent.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when the ability to control the environment changes.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public final class EnvironmentControlChangeEvent
+        implements com.android.extensions.xr.space.SpatialStateEvent {
+
+    @Deprecated
+    public EnvironmentControlChangeEvent(boolean allowed) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Whether or not the receiver can control the environment.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public boolean getEnvironmentControlAllowed() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentVisibilityChangeEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentVisibilityChangeEvent.java
new file mode 100644
index 0000000..233f5c7
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/EnvironmentVisibilityChangeEvent.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * For all resumed top activities with this spatialstate callback set, this is called whenever the
+ * VR background changes. This is also called when an activity becomes top resumed.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public final class EnvironmentVisibilityChangeEvent
+        implements com.android.extensions.xr.space.SpatialStateEvent {
+
+    @Deprecated
+    public EnvironmentVisibilityChangeEvent(int state) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Visibility state of the VR background
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public int getEnvironmentState() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/HitTestResult.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/HitTestResult.java
new file mode 100644
index 0000000..007a3ca
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/HitTestResult.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Hit test result.
+ *
+ * @see androidx.xr.extensions.XrExtensions#hitTest
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class HitTestResult {
+
+    HitTestResult() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Distance from the ray origin to the hit position. */
+    public float getDistance() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The hit position in task coordinates. */
+    public com.android.extensions.xr.node.Vec3 getHitPosition() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Normal of the surface at the collision point, if known. */
+    public com.android.extensions.xr.node.Vec3 getSurfaceNormal() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The type of surface that was hit. */
+    public int getSurfaceType() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Whether or not the virtual background environment is visible. */
+    public boolean getVirtualEnvironmentIsVisible() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The ray has hit a 3D object */
+    public static final int SURFACE_3D_OBJECT = 2; // 0x2
+
+    /** The ray has hit a 2D panel */
+    public static final int SURFACE_PANEL = 1; // 0x1
+
+    /** The ray has hit something unknown or nothing at all */
+    public static final int SURFACE_UNKNOWN = 0; // 0x0
+
+    @SuppressWarnings({"unchecked", "deprecation", "all"})
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+    public static final class Builder {
+
+        public Builder(
+                float distance,
+                com.android.extensions.xr.node.Vec3 hitPosition,
+                boolean virtualEnvironmentIsVisible,
+                int surfaceType) {
+            throw new RuntimeException("Stub!");
+        }
+
+        /** Sets the surface vector. */
+        public com.android.extensions.xr.space.HitTestResult.Builder setSurfaceNormal(
+                com.android.extensions.xr.node.Vec3 surfaceNormal) {
+            throw new RuntimeException("Stub!");
+        }
+
+        /** Builds the HitTestResult. */
+        public com.android.extensions.xr.space.HitTestResult build() {
+            throw new RuntimeException("Stub!");
+        }
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilities.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilities.java
new file mode 100644
index 0000000..a9f25752
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilities.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/** Represents a set of capabilities an activity has. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialCapabilities {
+
+    public SpatialCapabilities() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** Returns true if the capability is available. */
+    public boolean get(int capability) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** The activity can set its own environment. */
+    public static final int APP_ENVIRONMENTS_CAPABLE = 3; // 0x3
+
+    /** The activity can enable or disable passthrough. */
+    public static final int PASSTHROUGH_CONTROL_CAPABLE = 2; // 0x2
+
+    /**
+     * The activity can create 3D contents.
+     *
+     * <p>This capability allows neither spatial panel creation nor spatial activity embedding.
+     */
+    public static final int SPATIAL_3D_CONTENTS_CAPABLE = 1; // 0x1
+
+    /**
+     * The activity can launch another activity on a spatial panel to spatially embed it.
+     *
+     * <p>This capability allows neither spatial panel creation nor 3D content creation.
+     */
+    public static final int SPATIAL_ACTIVITY_EMBEDDING_CAPABLE = 5; // 0x5
+
+    /** The activity can use spatial audio. */
+    public static final int SPATIAL_AUDIO_CAPABLE = 4; // 0x4
+
+    /**
+     * The activity can spatialize itself by adding a spatial panel.
+     *
+     * <p>This capability allows neither 3D content creation nor spatial activity embedding.
+     */
+    public static final int SPATIAL_UI_CAPABLE = 0; // 0x0
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilityChangeEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilityChangeEvent.java
new file mode 100644
index 0000000..be1b160
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialCapabilityChangeEvent.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Triggers when the spatial capability set has changed.
+ *
+ * @deprecated Use SpatialState instead.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public final class SpatialCapabilityChangeEvent
+        implements com.android.extensions.xr.space.SpatialStateEvent {
+
+    @Deprecated
+    public SpatialCapabilityChangeEvent(
+            com.android.extensions.xr.space.SpatialCapabilities capabilities) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the enabled capabailities.
+     *
+     * @deprecated Use SpatialState instead.
+     */
+    @Deprecated
+    public com.android.extensions.xr.space.SpatialCapabilities getCurrentCapabilities() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialState.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialState.java
new file mode 100644
index 0000000..bd2356f
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialState.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * An interface that represents an activity's spatial state.
+ *
+ * <p>An object of the class is effectively immutable. Once the object, which is a "snapshot" of the
+ * activity's spatial state, is returned to the client, each getters will always return the same
+ * value even if the activity's state later changes.
+ *
+ * @see androidx.xr.extensions.XrExtensions#registerSpatialStateCallback
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SpatialState {
+
+    SpatialState() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets spatial bounds of the activity. When in full space mode, (infinity, infinity, infinity)
+     * is returned.
+     *
+     * @see androidx.xr.extensions.space.Bounds
+     */
+    public com.android.extensions.xr.space.Bounds getBounds() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets spatial capabilities of the activity. Unlike other capabilities in Android, this may
+     * dynamically change based on the current mode the activity is in, whether the activity is the
+     * top one in its task, whether the task is the top visible one on the desktop, and so on.
+     *
+     * @see androidx.xr.extensions.space.SpatialCapabilities
+     */
+    public com.android.extensions.xr.space.SpatialCapabilities getSpatialCapabilities() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the environment visibility of the activity.
+     *
+     * @see androidx.xr.extensions.environment.EnvironmentVisibilityState
+     */
+    public com.android.extensions.xr.environment.EnvironmentVisibilityState
+            getEnvironmentVisibility() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the passthrough visibility of the activity.
+     *
+     * @see androidx.xr.extensions.environment.PassthroughVisibilityState
+     */
+    public com.android.extensions.xr.environment.PassthroughVisibilityState
+            getPassthroughVisibility() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * True if the scene node that is currently in use (if any) is the same as targetNode. When
+     * targetNode is null, this API returns true when no scene node is currently in use (i.e. the
+     * activity is not SPATIAL_UI_CAPABLE, the activity hasn't called attachSpatialScene API at all,
+     * or the activity hasn't called it again since the last detachSpatialScene API call.)
+     *
+     * @see androidx.xr.extensions.attachSpatialScene
+     */
+    public boolean isActiveSceneNode(com.android.extensions.xr.node.Node targetNode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * True if the window leash node that is currently in use (if any) is the same as targetNode.
+     * When targetNode is null, this API returns true when no window leash node is currently in use
+     * (i.e. the activity is not SPATIAL_UI_CAPABLE, the activity hasn't called attachSpatialScene
+     * API at all, or the activity hasn't called it again since the last detachSpatialScene API
+     * call.)
+     *
+     * @see androidx.xr.extensions.attachSpatialScene
+     */
+    public boolean isActiveWindowLeashNode(com.android.extensions.xr.node.Node targetNode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * True if the environment node that is currently in use (if any) is the same as targetNode.
+     * When targetNode is null, this API returns true when no environment node is currently in use
+     * (i.e. the activity is not APP_ENVIRONMENTS_CAPABLE, the activity hasn't called
+     * attachSpatialEnvironment API at all, or the activity hasn't called it again since the last
+     * detachSpatialEnvironment API call.)
+     *
+     * <p>Note that as a special case, when isEnvironmentInherited() is true, the API returns false
+     * for a null targetNode even if your activity hasn't called attachSpatialEnvironment yet.
+     *
+     * @see androidx.xr.extensions.attachSpatialEnvironment
+     * @see androidx.xr.extensions.setFullSpaceModeWithEnvironmentInherited
+     */
+    public boolean isActiveEnvironmentNode(com.android.extensions.xr.node.Node targetNode) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * True if an activity-provided environment node is currently in use, and the node is one
+     * inherited from a different activity.
+     *
+     * @see androidx.xr.extensions.attachSpatialEnvironment
+     * @see androidx.xr.extensions.setFullSpaceModeWithEnvironmentInherited
+     */
+    public boolean isEnvironmentInherited() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the main window size. (0, 0) is returned when the activity is not SPATIAL_UI_CAPABLE.
+     *
+     * <p>When the activity is not SPATIAL_UI_CAPABLE, use android.content.res.Configuration to
+     * obtain the activity's size.
+     *
+     * @see androidx.xr.extensions.setMainWindowSize
+     * @see android.content.res.Configuration
+     */
+    public android.util.Size getMainWindowSize() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /**
+     * Gets the main window's aspect ratio preference. 0.0f is returned when there's no preference
+     * set via setPreferredAspectRatio API, or the activity is currently SPATIAL_UI_CAPABLE.
+     *
+     * <p>When SPATIAL_UI_CAPABLE, activities can set a preferred aspect ratio via ReformOptions,
+     * but that reform options setting won't be reflected to the value returned from this API.
+     *
+     * @see androidx.xr.extensions.setPreferredAspectRatio
+     * @see androidx.xr.extensions.node.ReformOptions
+     */
+    public float getPreferredAspectRatio() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** equals() */
+    public boolean equals(java.lang.Object other) {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** hashCode() */
+    public int hashCode() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** toString() */
+    public java.lang.String toString() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialStateEvent.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialStateEvent.java
new file mode 100644
index 0000000..7f520fb
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/space/SpatialStateEvent.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.space;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Base class for spatial state change events.
+ *
+ * @see androidx.xr.extensions.XrExtensions#setSpatialStateCallback
+ * @deprecated Use SpatialState instead.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated
+public interface SpatialStateEvent {}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/splitengine/SplitEngineBridge.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/splitengine/SplitEngineBridge.java
new file mode 100644
index 0000000..fcc89dd
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/splitengine/SplitEngineBridge.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.splitengine;
+
+import androidx.annotation.RestrictTo;
+
+/** Wrapper object around a native SplitEngineBridge. */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class SplitEngineBridge {
+
+    SplitEngineBridge() {
+        throw new RuntimeException("Stub!");
+    }
+
+    /** A handle to the shared memory split engine bridge. */
+    public long getNativeHandle() {
+        throw new RuntimeException("Stub!");
+    }
+}
diff --git a/xr/xr-stubs/src/main/java/com/android/extensions/xr/subspace/Subspace.java b/xr/xr-stubs/src/main/java/com/android/extensions/xr/subspace/Subspace.java
new file mode 100644
index 0000000..e8bb053
--- /dev/null
+++ b/xr/xr-stubs/src/main/java/com/android/extensions/xr/subspace/Subspace.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 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.android.extensions.xr.subspace;
+
+import androidx.annotation.RestrictTo;
+
+/**
+ * Handle to a subspace in the system scene graph.
+ *
+ * <p>A subspace by itself does not have any visual representation. It merely defines a local space
+ * in its parent space. Once created, 3D content can be rendered in the hierarchy of that subspace.
+ *
+ * <p>Note that {@link com.android.extensions.xr.subspace.Subspace Subspace} uses a right-hand
+ * coordinate system, i.e. +X points to the right, +Y up, and +Z points towards the camera.
+ */
+@SuppressWarnings({"unchecked", "deprecation", "all"})
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+public class Subspace {
+
+    Subspace() {
+        throw new RuntimeException("Stub!");
+    }
+}